save progress

This commit is contained in:
StellaOps Bot
2026-01-02 21:06:27 +02:00
parent f46bde5575
commit 3f197814c5
441 changed files with 21545 additions and 4306 deletions

View File

@@ -0,0 +1,122 @@
using System.Collections.Generic;
using StellaOps.Auth;
using StellaOps.Auth.Abstractions;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Auth.Abstractions.Tests;
public class AuthAbstractionsConstantsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AuthorityTelemetry_DefaultAttributesAreStable()
{
var attributes = AuthorityTelemetry.BuildDefaultResourceAttributes(typeof(AuthorityTelemetry).Assembly);
Assert.Equal(AuthorityTelemetry.ServiceName, attributes["service.name"]);
Assert.Equal(AuthorityTelemetry.ServiceNamespace, attributes["service.namespace"]);
Assert.Equal(AuthorityTelemetry.ResolveServiceVersion(typeof(AuthorityTelemetry).Assembly), attributes["service.version"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AuthenticationDefaults_AreStable()
{
Assert.Equal("StellaOpsBearer", StellaOpsAuthenticationDefaults.AuthenticationScheme);
Assert.Equal("StellaOps", StellaOpsAuthenticationDefaults.AuthenticationType);
Assert.Equal("StellaOps.Policy.", StellaOpsAuthenticationDefaults.PolicyPrefix);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ClaimTypes_AreStable()
{
var expected = new Dictionary<string, string>
{
[nameof(StellaOpsClaimTypes.Subject)] = "sub",
[nameof(StellaOpsClaimTypes.Tenant)] = "stellaops:tenant",
[nameof(StellaOpsClaimTypes.Project)] = "stellaops:project",
[nameof(StellaOpsClaimTypes.ClientId)] = "client_id",
[nameof(StellaOpsClaimTypes.ServiceAccount)] = "stellaops:service_account",
[nameof(StellaOpsClaimTypes.TokenId)] = "jti",
[nameof(StellaOpsClaimTypes.AuthenticationMethod)] = "amr",
[nameof(StellaOpsClaimTypes.Scope)] = "scope",
[nameof(StellaOpsClaimTypes.ScopeItem)] = "scp",
[nameof(StellaOpsClaimTypes.Audience)] = "aud",
[nameof(StellaOpsClaimTypes.IdentityProvider)] = "stellaops:idp",
[nameof(StellaOpsClaimTypes.OperatorReason)] = "stellaops:operator_reason",
[nameof(StellaOpsClaimTypes.OperatorTicket)] = "stellaops:operator_ticket",
[nameof(StellaOpsClaimTypes.QuotaReason)] = "stellaops:quota_reason",
[nameof(StellaOpsClaimTypes.QuotaTicket)] = "stellaops:quota_ticket",
[nameof(StellaOpsClaimTypes.BackfillReason)] = "stellaops:backfill_reason",
[nameof(StellaOpsClaimTypes.BackfillTicket)] = "stellaops:backfill_ticket",
[nameof(StellaOpsClaimTypes.PolicyDigest)] = "stellaops:policy_digest",
[nameof(StellaOpsClaimTypes.PolicyTicket)] = "stellaops:policy_ticket",
[nameof(StellaOpsClaimTypes.PolicyReason)] = "stellaops:policy_reason",
[nameof(StellaOpsClaimTypes.PackRunId)] = "stellaops:pack_run_id",
[nameof(StellaOpsClaimTypes.PackGateId)] = "stellaops:pack_gate_id",
[nameof(StellaOpsClaimTypes.PackPlanHash)] = "stellaops:pack_plan_hash",
[nameof(StellaOpsClaimTypes.PolicyOperation)] = "stellaops:policy_operation",
[nameof(StellaOpsClaimTypes.IncidentReason)] = "stellaops:incident_reason",
[nameof(StellaOpsClaimTypes.VulnerabilityEnvironment)] = "stellaops:attr:env",
[nameof(StellaOpsClaimTypes.VulnerabilityOwner)] = "stellaops:attr:owner",
[nameof(StellaOpsClaimTypes.VulnerabilityBusinessTier)] = "stellaops:attr:business_tier",
[nameof(StellaOpsClaimTypes.SessionId)] = "sid"
};
Assert.Equal(expected[nameof(StellaOpsClaimTypes.Subject)], StellaOpsClaimTypes.Subject);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.Tenant)], StellaOpsClaimTypes.Tenant);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.Project)], StellaOpsClaimTypes.Project);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.ClientId)], StellaOpsClaimTypes.ClientId);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.ServiceAccount)], StellaOpsClaimTypes.ServiceAccount);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.TokenId)], StellaOpsClaimTypes.TokenId);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.AuthenticationMethod)], StellaOpsClaimTypes.AuthenticationMethod);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.Scope)], StellaOpsClaimTypes.Scope);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.ScopeItem)], StellaOpsClaimTypes.ScopeItem);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.Audience)], StellaOpsClaimTypes.Audience);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.IdentityProvider)], StellaOpsClaimTypes.IdentityProvider);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.OperatorReason)], StellaOpsClaimTypes.OperatorReason);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.OperatorTicket)], StellaOpsClaimTypes.OperatorTicket);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.QuotaReason)], StellaOpsClaimTypes.QuotaReason);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.QuotaTicket)], StellaOpsClaimTypes.QuotaTicket);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.BackfillReason)], StellaOpsClaimTypes.BackfillReason);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.BackfillTicket)], StellaOpsClaimTypes.BackfillTicket);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.PolicyDigest)], StellaOpsClaimTypes.PolicyDigest);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.PolicyTicket)], StellaOpsClaimTypes.PolicyTicket);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.PolicyReason)], StellaOpsClaimTypes.PolicyReason);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.PackRunId)], StellaOpsClaimTypes.PackRunId);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.PackGateId)], StellaOpsClaimTypes.PackGateId);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.PackPlanHash)], StellaOpsClaimTypes.PackPlanHash);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.PolicyOperation)], StellaOpsClaimTypes.PolicyOperation);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.IncidentReason)], StellaOpsClaimTypes.IncidentReason);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.VulnerabilityEnvironment)], StellaOpsClaimTypes.VulnerabilityEnvironment);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.VulnerabilityOwner)], StellaOpsClaimTypes.VulnerabilityOwner);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.VulnerabilityBusinessTier)], StellaOpsClaimTypes.VulnerabilityBusinessTier);
Assert.Equal(expected[nameof(StellaOpsClaimTypes.SessionId)], StellaOpsClaimTypes.SessionId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void HttpHeaderNames_AreStable()
{
Assert.Equal("X-StellaOps-Tenant", StellaOpsHttpHeaderNames.Tenant);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ServiceIdentities_AreStable()
{
Assert.Equal("policy-engine", StellaOpsServiceIdentities.PolicyEngine);
Assert.Equal("cartographer", StellaOpsServiceIdentities.Cartographer);
Assert.Equal("vuln-explorer", StellaOpsServiceIdentities.VulnExplorer);
Assert.Equal("signals", StellaOpsServiceIdentities.Signals);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TenancyDefaults_AreStable()
{
Assert.Equal("*", StellaOpsTenancyDefaults.AnyProject);
}
}

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Auth.Abstractions.Tests;
public class NetworkMaskMatcherTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Parse_SingleAddress_YieldsHostMask()
{
var mask = NetworkMask.Parse("192.168.1.42");
@@ -20,7 +20,7 @@ public class NetworkMaskMatcherTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Parse_Cidr_NormalisesHostBits()
{
var mask = NetworkMask.Parse("10.0.15.9/20");
@@ -31,7 +31,7 @@ public class NetworkMaskMatcherTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Contains_ReturnsFalse_ForMismatchedAddressFamily()
{
var mask = NetworkMask.Parse("192.168.0.0/16");
@@ -40,7 +40,7 @@ public class NetworkMaskMatcherTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Matcher_AllowsAll_WhenStarProvided()
{
var matcher = new NetworkMaskMatcher(new[] { "*" });
@@ -51,7 +51,7 @@ public class NetworkMaskMatcherTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Matcher_ReturnsFalse_WhenNoMasksConfigured()
{
var matcher = new NetworkMaskMatcher(Array.Empty<string>());
@@ -62,7 +62,7 @@ public class NetworkMaskMatcherTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Matcher_SupportsIpv4AndIpv6Masks()
{
var matcher = new NetworkMaskMatcher(new[] { "192.168.0.0/24", "::1/128" });
@@ -74,10 +74,49 @@ public class NetworkMaskMatcherTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Matcher_Throws_ForInvalidEntries()
{
var exception = Assert.Throws<FormatException>(() => new NetworkMaskMatcher(new[] { "invalid-mask" }));
Assert.Contains("invalid-mask", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryParse_RejectsInvalidPrefixes()
{
Assert.False(NetworkMask.TryParse("10.0.0.0/33", out _));
Assert.False(NetworkMask.TryParse("10.0.0.0/-1", out _));
Assert.False(NetworkMask.TryParse("::1/129", out _));
Assert.False(NetworkMask.TryParse("::1/-1", out _));
Assert.False(NetworkMask.TryParse("10.0.0.0/abc", out _));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryParse_AllowsBoundaryPrefixes()
{
Assert.True(NetworkMask.TryParse("0.0.0.0/0", out var ipv4All));
Assert.True(ipv4All.Contains(IPAddress.Parse("203.0.113.10")));
Assert.True(NetworkMask.TryParse("::/0", out var ipv6All));
Assert.True(ipv6All.Contains(IPAddress.IPv6Loopback));
Assert.True(NetworkMask.TryParse("127.0.0.1/32", out var ipv4Host));
Assert.True(ipv4Host.Contains(IPAddress.Parse("127.0.0.1")));
Assert.True(NetworkMask.TryParse("::1/128", out var ipv6Host));
Assert.True(ipv6Host.Contains(IPAddress.IPv6Loopback));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Matcher_StaticAllowAllAndDenyAllBehaveAsExpected()
{
Assert.False(NetworkMaskMatcher.AllowAll.IsEmpty);
Assert.True(NetworkMaskMatcher.AllowAll.IsAllowed(IPAddress.Parse("192.0.2.10")));
Assert.True(NetworkMaskMatcher.DenyAll.IsEmpty);
Assert.False(NetworkMaskMatcher.DenyAll.IsAllowed(IPAddress.Parse("192.0.2.10")));
}
}

View File

@@ -10,7 +10,7 @@ namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsPrincipalBuilderTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void NormalizedScopes_AreSortedDeduplicatedLowerCased()
{
var builder = new StellaOpsPrincipalBuilder()
@@ -27,10 +27,11 @@ public class StellaOpsPrincipalBuilderTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Build_ConstructsClaimsPrincipalWithNormalisedValues()
{
var now = DateTimeOffset.UtcNow;
var now = new DateTimeOffset(2024, 10, 20, 12, 30, 0, TimeSpan.Zero);
var tokenId = "token-123";
var builder = new StellaOpsPrincipalBuilder()
.WithSubject(" user-1 ")
.WithClientId(" cli-01 ")
@@ -38,7 +39,7 @@ public class StellaOpsPrincipalBuilderTests
.WithName(" Jane Doe ")
.WithIdentityProvider(" internal ")
.WithSessionId(" session-123 ")
.WithTokenId(Guid.NewGuid().ToString("N"))
.WithTokenId(tokenId)
.WithAuthenticationMethod("password")
.WithAuthenticationType(" custom ")
.WithScopes(new[] { "Concelier.Jobs.Trigger", "AUTHORITY.USERS.MANAGE" })
@@ -57,6 +58,7 @@ public class StellaOpsPrincipalBuilderTests
Assert.Equal("default", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("internal", principal.FindFirstValue(StellaOpsClaimTypes.IdentityProvider));
Assert.Equal("session-123", principal.FindFirstValue(StellaOpsClaimTypes.SessionId));
Assert.Equal(tokenId, principal.FindFirstValue(StellaOpsClaimTypes.TokenId));
Assert.Equal("value", principal.FindFirstValue("custom"));
var scopeClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.ScopeItem).Select(claim => claim.Value).ToArray();

View File

@@ -10,7 +10,7 @@ namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsProblemResultFactoryTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void AuthenticationRequired_ReturnsCanonicalProblem()
{
var result = StellaOpsProblemResultFactory.AuthenticationRequired(instance: "/jobs");
@@ -25,7 +25,7 @@ public class StellaOpsProblemResultFactoryTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void InvalidToken_UsesProvidedDetail()
{
var result = StellaOpsProblemResultFactory.InvalidToken("expired refresh token");
@@ -37,7 +37,7 @@ public class StellaOpsProblemResultFactoryTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void InsufficientScope_AddsScopeExtensions()
{
var result = StellaOpsProblemResultFactory.InsufficientScope(
@@ -54,4 +54,17 @@ public class StellaOpsProblemResultFactoryTests
Assert.Equal(new[] { StellaOpsScopes.AuthorityUsersManage }, Assert.IsType<string[]>(details.Extensions["granted_scopes"]));
Assert.Equal("/jobs/trigger", details.Instance);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Forbidden_UsesDefaultDetail()
{
var result = StellaOpsProblemResultFactory.Forbidden();
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode);
Assert.Equal("https://docs.stella-ops.org/problems/forbidden", details.Type);
Assert.Equal("The authenticated principal is not authorised to access this resource.", details.Detail);
Assert.Equal("forbidden", details.Extensions["error"]);
}
}

View File

@@ -1,3 +1,5 @@
using System;
using System.Linq;
using StellaOps.Auth.Abstractions;
using Xunit;
@@ -9,7 +11,7 @@ namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsScopesTests
{
[Trait("Category", TestCategories.Unit)]
[Theory]
[Theory]
[InlineData(StellaOpsScopes.AdvisoryRead)]
[InlineData(StellaOpsScopes.AdvisoryIngest)]
[InlineData(StellaOpsScopes.AdvisoryAiView)]
@@ -76,7 +78,7 @@ public class StellaOpsScopesTests
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[Theory]
[InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)]
[InlineData(" VEX:Ingest ", StellaOpsScopes.VexIngest)]
[InlineData("AOC:VERIFY", StellaOpsScopes.AocVerify)]
@@ -99,5 +101,37 @@ public class StellaOpsScopesTests
{
Assert.Equal(expected, StellaOpsScopes.Normalize(input));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void IsKnown_ReturnsTrueForBuiltInScopes()
{
Assert.True(StellaOpsScopes.IsKnown(StellaOpsScopes.ConcelierJobsTrigger));
Assert.True(StellaOpsScopes.IsKnown("Concelier.Jobs.Trigger"));
Assert.False(StellaOpsScopes.IsKnown("unknown:scope"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void All_IncludesEveryPublicConstantScope()
{
var expected = typeof(StellaOpsScopes)
.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
.Where(field => field is { IsLiteral: true, IsInitOnly: false } && field.FieldType == typeof(string))
.Select(field => (string)field.GetRawConstantValue()!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var actual = StellaOpsScopes.All.ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.True(expected.SetEquals(actual));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void All_IsStableAndSorted()
{
var ordered = StellaOpsScopes.All.OrderBy(scope => scope, StringComparer.Ordinal).ToArray();
Assert.Equal(ordered, StellaOpsScopes.All);
}
}
#pragma warning restore CS0618

View File

@@ -4,7 +4,7 @@
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<PackageId>StellaOps.Auth.Abstractions</PackageId>

View File

@@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reflection;
namespace StellaOps.Auth.Abstractions;
@@ -574,124 +577,8 @@ public static class StellaOpsScopes
/// </summary>
public const string GraphAdmin = "graph:admin";
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
{
ConcelierJobsTrigger,
ConcelierMerge,
AuthorityUsersManage,
AuthorityClientsManage,
AuthorityAuditRead,
Bypass,
UiRead,
ExceptionsApprove,
AdvisoryRead,
AdvisoryIngest,
AdvisoryAiView,
AdvisoryAiOperate,
AdvisoryAiAdmin,
VexRead,
VexIngest,
AocVerify,
SignalsRead,
SignalsWrite,
SignalsAdmin,
AirgapSeal,
AirgapImport,
AirgapStatusRead,
PolicyWrite,
PolicyAuthor,
PolicyEdit,
PolicyRead,
PolicyReview,
PolicySubmit,
PolicyApprove,
PolicyOperate,
PolicyPublish,
PolicyPromote,
PolicyAudit,
PolicyRun,
PolicyActivate,
PolicySimulate,
FindingsRead,
EffectiveWrite,
GraphRead,
VulnView,
VulnInvestigate,
VulnOperate,
VulnAudit,
#pragma warning disable CS0618 // track removal once legacy scope dropped
VulnRead,
#pragma warning restore CS0618
ObservabilityRead,
TimelineRead,
TimelineWrite,
EvidenceCreate,
EvidenceRead,
EvidenceHold,
AttestRead,
ObservabilityIncident,
ExportViewer,
ExportOperator,
ExportAdmin,
NotifyViewer,
NotifyOperator,
NotifyAdmin,
IssuerDirectoryRead,
IssuerDirectoryWrite,
IssuerDirectoryAdmin,
NotifyEscalate,
PacksRead,
PacksWrite,
PacksRun,
PacksApprove,
GraphWrite,
GraphExport,
GraphSimulate,
OrchRead,
OrchOperate,
OrchBackfill,
OrchQuota,
AuthorityTenantsRead,
AuthorityTenantsWrite,
AuthorityUsersRead,
AuthorityUsersWrite,
AuthorityRolesRead,
AuthorityRolesWrite,
AuthorityClientsRead,
AuthorityClientsWrite,
AuthorityTokensRead,
AuthorityTokensRevoke,
AuthorityBrandingRead,
AuthorityBrandingWrite,
UiAdmin,
ScannerRead,
ScannerScan,
ScannerExport,
ScannerWrite,
SchedulerRead,
SchedulerOperate,
SchedulerAdmin,
AttestCreate,
AttestAdmin,
SignerRead,
SignerSign,
SignerRotate,
SignerAdmin,
SbomRead,
SbomWrite,
SbomAttest,
ReleaseRead,
ReleaseWrite,
ReleasePublish,
ReleaseBypass,
ZastavaRead,
ZastavaTrigger,
ZastavaAdmin,
ExceptionsRead,
ExceptionsWrite,
ExceptionsRequest,
GraphAdmin
};
private static readonly IReadOnlyList<string> AllScopes = BuildAllScopes();
private static readonly HashSet<string> KnownScopes = new(AllScopes, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Normalises a scope string (trim/convert to lower case).
@@ -720,5 +607,19 @@ public static class StellaOpsScopes
/// <summary>
/// Returns the full set of built-in scopes.
/// </summary>
public static IReadOnlyCollection<string> All => KnownScopes;
public static IReadOnlyCollection<string> All => AllScopes;
private static IReadOnlyList<string> BuildAllScopes()
{
var values = typeof(StellaOpsScopes)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(static field => field is { IsLiteral: true, IsInitOnly: false } && field.FieldType == typeof(string))
.Select(static field => (string)field.GetRawConstantValue()!)
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
return new ReadOnlyCollection<string>(values);
}
}

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0078-M | DONE | Maintainability audit for StellaOps.Auth.Abstractions. |
| AUDIT-0078-T | DONE | Test coverage audit for StellaOps.Auth.Abstractions. |
| AUDIT-0078-A | TODO | Pending approval for changes. |
| AUDIT-0078-A | DONE | Scope ordering, warning discipline, and coverage gaps addressed. |

View File

@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Client;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Auth.Client.Tests;
public class MessagingTokenCacheTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAsync_InvalidatesExpiredEntries()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var cacheFactory = new FakeDistributedCacheFactory(timeProvider);
var cache = new MessagingTokenCache(cacheFactory, timeProvider, TimeSpan.Zero);
var entry = new StellaOpsTokenCacheEntry("token", "Bearer", timeProvider.GetUtcNow() + TimeSpan.FromSeconds(5), new[] { "scope" });
await cache.SetAsync("key", entry);
var retrieved = await cache.GetAsync("key");
Assert.NotNull(retrieved);
timeProvider.Advance(TimeSpan.FromSeconds(10));
retrieved = await cache.GetAsync("key");
Assert.Null(retrieved);
Assert.Equal(1, cacheFactory.Cache.InvalidateCallCount);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task RemoveAsync_InvokesUnderlyingInvalidation()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var cacheFactory = new FakeDistributedCacheFactory(timeProvider);
var cache = new MessagingTokenCache(cacheFactory, timeProvider, TimeSpan.Zero);
var entry = new StellaOpsTokenCacheEntry("token", "Bearer", timeProvider.GetUtcNow() + TimeSpan.FromMinutes(1), new[] { "scope" });
await cache.SetAsync("key", entry);
await cache.RemoveAsync("key");
Assert.Equal(1, cacheFactory.Cache.InvalidateCallCount);
}
private sealed class FakeDistributedCacheFactory : IDistributedCacheFactory
{
private readonly TimeProvider timeProvider;
public FakeDistributedCacheFactory(TimeProvider timeProvider)
{
this.timeProvider = timeProvider;
Cache = new FakeDistributedCache<StellaOpsTokenCacheEntry>(timeProvider);
}
public string ProviderName => "fake";
public FakeDistributedCache<StellaOpsTokenCacheEntry> Cache { get; }
public IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options)
=> new FakeDistributedCache<TKey, TValue>(timeProvider);
public IDistributedCache<TValue> Create<TValue>(CacheOptions options)
{
if (typeof(TValue) == typeof(StellaOpsTokenCacheEntry))
{
return (IDistributedCache<TValue>)(object)Cache;
}
return new FakeDistributedCache<TValue>(timeProvider);
}
}
private sealed class FakeDistributedCache<TValue> : FakeDistributedCache<string, TValue>, IDistributedCache<TValue>
{
public FakeDistributedCache(TimeProvider timeProvider)
: base(timeProvider)
{
}
}
private class FakeDistributedCache<TKey, TValue> : IDistributedCache<TKey, TValue>
where TKey : notnull
{
private readonly Dictionary<TKey, CacheEntry> entries = new();
private readonly TimeProvider timeProvider;
public FakeDistributedCache(TimeProvider timeProvider)
{
this.timeProvider = timeProvider;
}
public string ProviderName => "fake";
public int InvalidateCallCount { get; private set; }
public ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default)
{
if (!entries.TryGetValue(key, out var entry))
{
return ValueTask.FromResult(CacheResult<TValue>.Miss());
}
return ValueTask.FromResult(CacheResult<TValue>.Found(entry.Value));
}
public ValueTask SetAsync(TKey key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
{
DateTimeOffset? expiresAt = null;
if (options?.AbsoluteExpiration is not null)
{
expiresAt = options.AbsoluteExpiration;
}
else if (options?.TimeToLive is not null)
{
expiresAt = timeProvider.GetUtcNow() + options.TimeToLive.Value;
}
entries[key] = new CacheEntry(value, expiresAt);
return ValueTask.CompletedTask;
}
public ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default)
{
InvalidateCallCount++;
return ValueTask.FromResult(entries.Remove(key));
}
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
{
var count = entries.Count;
entries.Clear();
return ValueTask.FromResult((long)count);
}
public async ValueTask<TValue> GetOrSetAsync(
TKey key,
Func<CancellationToken, ValueTask<TValue>> factory,
CacheEntryOptions? options = null,
CancellationToken cancellationToken = default)
{
var result = await GetAsync(key, cancellationToken).ConfigureAwait(false);
if (result.HasValue)
{
return result.Value;
}
var value = await factory(cancellationToken).ConfigureAwait(false);
await SetAsync(key, value, options, cancellationToken).ConfigureAwait(false);
return value;
}
private readonly record struct CacheEntry(TValue Value, DateTimeOffset? ExpiresAt);
}
}

View File

@@ -25,7 +25,7 @@ namespace StellaOps.Auth.Client.Tests;
public class ServiceCollectionExtensionsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task AddStellaOpsAuthClient_ConfiguresRetryPolicy()
{
var services = new ServiceCollection();
@@ -81,7 +81,7 @@ public class ServiceCollectionExtensionsTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void EnsureEgressAllowed_InvokesPolicyWhenAuthorityProvided()
{
var services = new ServiceCollection();
@@ -138,7 +138,7 @@ public class ServiceCollectionExtensionsTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task AddStellaOpsApiAuthentication_AttachesPatAndTenantHeader()
{
var services = new ServiceCollection();
@@ -185,7 +185,7 @@ public class ServiceCollectionExtensionsTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task AddStellaOpsApiAuthentication_UsesClientCredentialsWithCaching()
{
var services = new ServiceCollection();
@@ -217,14 +217,27 @@ public class ServiceCollectionExtensionsTests
options.Tenant = "tenant-oauth";
});
var secondHandler = new RecordingHttpMessageHandler();
services.AddHttpClient("notify2")
.ConfigurePrimaryHttpMessageHandler(() => secondHandler)
.AddStellaOpsApiAuthentication(options =>
{
options.Mode = StellaOpsApiAuthMode.ClientCredentials;
options.Scope = "notify.read";
options.Tenant = "tenant-oauth";
});
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("notify");
var factory = provider.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient("notify");
await client.GetAsync("https://notify.example/api");
await client.GetAsync("https://notify.example/api");
Assert.Equal(2, handler.AuthorizationHistory.Count);
Assert.Equal(1, recordingTokenClient.ClientCredentialsCallCount);
Assert.Equal(1, recordingTokenClient.GetCachedTokenCallCount);
Assert.Equal(1, recordingTokenClient.CacheTokenCallCount);
Assert.All(handler.AuthorizationHistory, header =>
{
Assert.NotNull(header);
@@ -233,6 +246,15 @@ public class ServiceCollectionExtensionsTests
});
Assert.All(handler.TenantHeaders, value => Assert.Equal("tenant-oauth", value));
var clientTwo = factory.CreateClient("notify2");
await clientTwo.GetAsync("https://notify.example/api");
Assert.Equal(1, recordingTokenClient.ClientCredentialsCallCount);
Assert.True(recordingTokenClient.GetCachedTokenCallCount >= 2);
Assert.Equal(1, recordingTokenClient.CacheTokenCallCount);
Assert.Single(secondHandler.AuthorizationHistory);
Assert.Equal("token-1", secondHandler.AuthorizationHistory[0]!.Parameter);
// Advance beyond expiry buffer to force refresh.
fakeTime.Advance(TimeSpan.FromMinutes(2));
await client.GetAsync("https://notify.example/api");
@@ -240,6 +262,89 @@ public class ServiceCollectionExtensionsTests
Assert.Equal(3, handler.AuthorizationHistory.Count);
Assert.Equal("token-2", handler.AuthorizationHistory[^1]!.Parameter);
Assert.Equal(2, recordingTokenClient.ClientCredentialsCallCount);
Assert.True(recordingTokenClient.GetCachedTokenCallCount >= 2);
Assert.True(recordingTokenClient.CacheTokenCallCount >= 2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AddStellaOpsApiAuthentication_UsesPasswordFlowWithCaching()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddStellaOpsAuthClient(options =>
{
options.Authority = "https://authority.test";
options.DiscoveryCacheLifetime = TimeSpan.FromMinutes(1);
options.JwksCacheLifetime = TimeSpan.FromMinutes(1);
options.AllowOfflineCacheFallback = false;
});
var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T00:00:00Z"));
services.AddSingleton<TimeProvider>(fakeTime);
var recordingTokenClient = new RecordingTokenClient(fakeTime);
services.AddSingleton<IStellaOpsTokenClient>(recordingTokenClient);
var handler = new RecordingHttpMessageHandler();
services.AddHttpClient("vuln")
.ConfigurePrimaryHttpMessageHandler(() => handler)
.AddStellaOpsApiAuthentication(options =>
{
options.Mode = StellaOpsApiAuthMode.Password;
options.Username = "user1";
options.Password = "pass1";
options.Scope = "vuln.view";
});
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("vuln");
await client.GetAsync("https://vuln.example/api");
await client.GetAsync("https://vuln.example/api");
Assert.Equal(2, handler.AuthorizationHistory.Count);
Assert.Equal(1, recordingTokenClient.PasswordCallCount);
Assert.Equal(1, recordingTokenClient.GetCachedTokenCallCount);
Assert.Equal(1, recordingTokenClient.CacheTokenCallCount);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AddStellaOpsAuthClient_DisablesRetriesWhenConfigured()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddStellaOpsAuthClient(options =>
{
options.Authority = "https://authority.test";
options.EnableRetries = false;
options.DiscoveryCacheLifetime = TimeSpan.FromMinutes(1);
options.JwksCacheLifetime = TimeSpan.FromMinutes(1);
options.AllowOfflineCacheFallback = false;
});
var attemptCount = 0;
services.AddHttpClient<StellaOpsDiscoveryCache>()
.ConfigureHttpMessageHandlerBuilder(builder =>
{
builder.PrimaryHandler = new LambdaHttpMessageHandler((_, _) =>
{
attemptCount++;
return Task.FromResult(CreateResponse(HttpStatusCode.InternalServerError, "{}"));
});
});
using var provider = services.BuildServiceProvider();
var cache = provider.GetRequiredService<StellaOpsDiscoveryCache>();
await Assert.ThrowsAsync<HttpRequestException>(() => cache.GetAsync(CancellationToken.None));
Assert.Equal(1, attemptCount);
}
private sealed class RecordingHttpMessageHandler : HttpMessageHandler
@@ -331,6 +436,7 @@ public class ServiceCollectionExtensionsTests
{
private readonly FakeTimeProvider timeProvider;
private int tokenCounter;
private StellaOpsTokenCacheEntry? cachedEntry;
public RecordingTokenClient(FakeTimeProvider timeProvider)
{
@@ -338,6 +444,9 @@ public class ServiceCollectionExtensionsTests
}
public int ClientCredentialsCallCount { get; private set; }
public int PasswordCallCount { get; private set; }
public int GetCachedTokenCallCount { get; private set; }
public int CacheTokenCallCount { get; private set; }
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
@@ -352,23 +461,46 @@ public class ServiceCollectionExtensionsTests
null,
"{}");
return Task.FromResult(result);
}
return Task.FromResult(result);
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
PasswordCallCount++;
var tokenId = Interlocked.Increment(ref tokenCounter);
var result = new StellaOpsTokenResult(
$"token-{tokenId}",
"Bearer",
timeProvider.GetUtcNow().AddMinutes(1),
scope is null ? Array.Empty<string>() : new[] { scope },
null,
null,
"{}");
return Task.FromResult(result);
}
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new JsonWebKeySet());
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
{
GetCachedTokenCallCount++;
return ValueTask.FromResult(cachedEntry);
}
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
{
CacheTokenCallCount++;
cachedEntry = entry;
return ValueTask.CompletedTask;
}
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
{
cachedEntry = null;
return ValueTask.CompletedTask;
}
}
}

View File

@@ -16,7 +16,7 @@ namespace StellaOps.Auth.Client.Tests;
public class StellaOpsDiscoveryCacheTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task GetAsync_UsesOfflineFallbackWithinTolerance()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
@@ -56,7 +56,7 @@ public class StellaOpsDiscoveryCacheTests
Assert.Equal(new Uri("https://authority.test/connect/token"), configuration.TokenEndpoint);
Assert.Equal(2, callCount);
var offlineExpiry = GetOfflineExpiry(cache);
var offlineExpiry = cache.OfflineExpiresAt;
Assert.True(offlineExpiry > timeProvider.GetUtcNow());
timeProvider.Advance(options.OfflineCacheTolerance + TimeSpan.FromSeconds(1));
@@ -127,10 +127,4 @@ public class StellaOpsDiscoveryCacheTests
}
}
private static DateTimeOffset GetOfflineExpiry(StellaOpsDiscoveryCache cache)
{
var field = typeof(StellaOpsDiscoveryCache).GetField("offlineExpiresAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(field);
return (DateTimeOffset)field!.GetValue(cache)!;
}
}

View File

@@ -0,0 +1,123 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Client;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Auth.Client.Tests;
public class StellaOpsJwksCacheTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAsync_UsesOfflineFallbackWithinTolerance()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var discoveryHandler = new StubHttpMessageHandler((_, _) =>
Task.FromResult(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}")));
var discoveryClient = new HttpClient(discoveryHandler);
var jwksCallCount = 0;
var jwksHandler = new StubHttpMessageHandler((_, _) =>
{
jwksCallCount++;
if (jwksCallCount == 1)
{
return Task.FromResult(CreateJsonResponse("{\"keys\":[]}"));
}
throw new HttpRequestException("offline");
});
var jwksClient = new HttpClient(jwksHandler);
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
JwksCacheLifetime = TimeSpan.FromMinutes(1),
DiscoveryCacheLifetime = TimeSpan.FromMinutes(1),
OfflineCacheTolerance = TimeSpan.FromMinutes(5),
AllowOfflineCacheFallback = true
};
options.Validate();
var monitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options);
var discoveryCache = new StellaOpsDiscoveryCache(discoveryClient, monitor, timeProvider, NullLogger<StellaOpsDiscoveryCache>.Instance);
var jwksCache = new StellaOpsJwksCache(jwksClient, discoveryCache, monitor, timeProvider, NullLogger<StellaOpsJwksCache>.Instance);
var keys = await jwksCache.GetAsync(CancellationToken.None);
Assert.NotNull(keys);
timeProvider.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(5));
keys = await jwksCache.GetAsync(CancellationToken.None);
Assert.NotNull(keys);
Assert.Equal(2, jwksCallCount);
var offlineExpiry = jwksCache.OfflineExpiresAt;
Assert.True(offlineExpiry > timeProvider.GetUtcNow());
timeProvider.Advance(options.OfflineCacheTolerance + TimeSpan.FromSeconds(1));
Assert.True(offlineExpiry < timeProvider.GetUtcNow());
await Assert.ThrowsAsync<HttpRequestException>(() => jwksCache.GetAsync(CancellationToken.None));
}
private static HttpResponseMessage CreateJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json)
{
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
}
};
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
{
this.responder = responder;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> responder(request, cancellationToken);
}
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
where T : class
{
private readonly T value;
public TestOptionsMonitor(T value)
{
this.value = value;
}
public T CurrentValue => value;
public T Get(string? name) => value;
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Client;
@@ -12,7 +13,7 @@ namespace StellaOps.Auth.Client.Tests;
public class TokenCacheTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task InMemoryTokenCache_ExpiresEntries()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
@@ -31,13 +32,13 @@ public class TokenCacheTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task FileTokenCache_PersistsEntries()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
try
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var cache = new FileTokenCache(directory, timeProvider, TimeSpan.Zero);
var entry = new StellaOpsTokenCacheEntry("token", "Bearer", timeProvider.GetUtcNow() + TimeSpan.FromMinutes(5), new[] { "scope" });
@@ -59,4 +60,40 @@ public class TokenCacheTests
}
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FileTokenCache_ReturnsNullOnInvalidPayload()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
try
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var cache = new FileTokenCache(directory, timeProvider, TimeSpan.Zero);
Directory.CreateDirectory(directory);
var key = "invalid-key";
var path = Path.Combine(directory, $"{ComputeHash(key)}.json");
await File.WriteAllTextAsync(path, "{not-json}");
var retrieved = await cache.GetAsync(key);
Assert.Null(retrieved);
}
finally
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
}
private static string ComputeHash(string key)
{
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(key);
var hash = sha.ComputeHash(bytes);
return Convert.ToHexString(hash);
}
}

View File

@@ -1,6 +1,8 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -81,8 +83,23 @@ public sealed class FileTokenCache : IStellaOpsTokenCache
try
{
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous);
var streamOptions = new FileStreamOptions
{
Mode = FileMode.Create,
Access = FileAccess.Write,
Share = FileShare.None,
Options = FileOptions.Asynchronous
};
if (!OperatingSystem.IsWindows())
{
streamOptions.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
}
await using var stream = new FileStream(path, streamOptions);
await JsonSerializer.SerializeAsync(stream, payload, serializerOptions, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
TryHardenPermissions(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
@@ -119,4 +136,31 @@ public sealed class FileTokenCache : IStellaOpsTokenCache
var hash = Convert.ToHexString(sha.ComputeHash(bytes));
return Path.Combine(cacheDirectory, $"{hash}.json");
}
private void TryHardenPermissions(string path)
{
try
{
if (OperatingSystem.IsWindows())
{
var identity = WindowsIdentity.GetCurrent();
if (identity.User is null)
{
return;
}
var security = new FileSecurity();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
security.AddAccessRule(new FileSystemAccessRule(identity.User, FileSystemRights.FullControl, AccessControlType.Allow));
new FileInfo(path).SetAccessControl(security);
return;
}
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
logger?.LogWarning(ex, "Failed to harden permissions for cache file '{Path}'.", path);
}
}
}

View File

@@ -66,7 +66,8 @@ public static class ServiceCollectionExtensions
{
var logger = provider.GetService<Microsoft.Extensions.Logging.ILogger<FileTokenCache>>();
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
return new FileTokenCache(cacheDirectory, TimeProvider.System, options.ExpirationSkew, logger);
var timeProvider = provider.GetService<TimeProvider>();
return new FileTokenCache(cacheDirectory, timeProvider, options.ExpirationSkew, logger);
}));
return services;
@@ -95,13 +96,27 @@ public static class ServiceCollectionExtensions
return builder;
}
private static void ConfigureResilience(ResiliencePipelineBuilder<HttpResponseMessage> builder)
private static void ConfigureResilience(ResiliencePipelineBuilder<HttpResponseMessage> builder, ResilienceHandlerContext context)
{
context.EnableReloads<StellaOpsAuthClientOptions>();
var options = context.GetOptions<StellaOpsAuthClientOptions>();
if (!options.EnableRetries || options.NormalizedRetryDelays.Count == 0)
{
return;
}
var delays = options.NormalizedRetryDelays;
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
MaxRetryAttempts = delays.Count,
DelayGenerator = args =>
{
var index = args.AttemptNumber < delays.Count ? args.AttemptNumber : delays.Count - 1;
return ValueTask.FromResult<TimeSpan?>(delays[index]);
},
BackoffType = DelayBackoffType.Constant,
ShouldHandle = static args => ValueTask.FromResult(
args.Outcome.Exception is not null ||
args.Outcome.Result?.StatusCode is HttpStatusCode.RequestTimeout

View File

@@ -5,7 +5,7 @@
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<PackageId>StellaOps.Auth.Client</PackageId>

View File

@@ -1,6 +1,7 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -22,6 +23,7 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
private readonly SemaphoreSlim refreshLock = new(1, 1);
private StellaOpsTokenResult? cachedToken;
private string? cachedTokenKey;
public StellaOpsBearerTokenHandler(
string clientName,
@@ -67,9 +69,11 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
var buffer = GetRefreshBuffer(options);
var now = timeProvider.GetUtcNow();
var clientOptions = authClientOptions.CurrentValue;
var cacheKey = BuildCacheKey(options, clientOptions);
var token = cachedToken;
if (token is not null && token.ExpiresAt - buffer > now)
if (token is not null && cachedTokenKey == cacheKey && token.ExpiresAt - buffer > now)
{
return token.AccessToken;
}
@@ -79,11 +83,30 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
{
token = cachedToken;
now = timeProvider.GetUtcNow();
if (token is not null && token.ExpiresAt - buffer > now)
if (token is not null && cachedTokenKey == cacheKey && token.ExpiresAt - buffer > now)
{
return token.AccessToken;
}
var cachedEntry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cachedEntry is not null && !cachedEntry.IsExpired(timeProvider, buffer))
{
cachedToken = new StellaOpsTokenResult(
cachedEntry.AccessToken,
cachedEntry.TokenType,
cachedEntry.ExpiresAtUtc,
cachedEntry.Scopes,
cachedEntry.RefreshToken,
cachedEntry.IdToken,
null);
cachedTokenKey = cacheKey;
return cachedEntry.AccessToken;
}
else if (cachedEntry is not null)
{
await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
}
StellaOpsTokenResult result = options.Mode switch
{
StellaOpsApiAuthMode.ClientCredentials => await tokenClient.RequestClientCredentialsTokenAsync(
@@ -100,6 +123,8 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
};
cachedToken = result;
cachedTokenKey = cacheKey;
await tokenClient.CacheTokenAsync(cacheKey, result.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
logger?.LogDebug("Issued access token for client {ClientName}; expires at {ExpiresAt}.", clientName, result.ExpiresAt);
return result.AccessToken;
}
@@ -120,4 +145,33 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
return buffer > authOptions.ExpirationSkew ? buffer : authOptions.ExpirationSkew;
}
private string BuildCacheKey(StellaOpsApiAuthenticationOptions apiOptions, StellaOpsAuthClientOptions clientOptions)
{
var resolvedScope = ResolveScope(apiOptions.Scope, clientOptions);
var authority = clientOptions.AuthorityUri?.ToString() ?? clientOptions.Authority;
var builder = new StringBuilder();
builder.Append("stellaops|");
builder.Append(clientName).Append('|');
builder.Append(authority).Append('|');
builder.Append(clientOptions.ClientId ?? string.Empty).Append('|');
builder.Append(apiOptions.Mode).Append('|');
builder.Append(resolvedScope ?? string.Empty).Append('|');
builder.Append(apiOptions.Username ?? string.Empty).Append('|');
builder.Append(apiOptions.Tenant ?? string.Empty);
return builder.ToString();
}
private static string? ResolveScope(string? scope, StellaOpsAuthClientOptions clientOptions)
{
var resolved = scope;
if (string.IsNullOrWhiteSpace(resolved) && clientOptions.NormalizedScopes.Count > 0)
{
resolved = string.Join(' ', clientOptions.NormalizedScopes);
}
return string.IsNullOrWhiteSpace(resolved) ? null : resolved.Trim();
}
}

View File

@@ -23,6 +23,9 @@ public sealed class StellaOpsDiscoveryCache
private DateTimeOffset cacheExpiresAt;
private DateTimeOffset offlineExpiresAt;
internal DateTimeOffset CacheExpiresAt => cacheExpiresAt;
internal DateTimeOffset OfflineExpiresAt => offlineExpiresAt;
public StellaOpsDiscoveryCache(HttpClient httpClient, IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor, TimeProvider? timeProvider = null, ILogger<StellaOpsDiscoveryCache>? logger = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));

View File

@@ -23,6 +23,9 @@ public sealed class StellaOpsJwksCache
private DateTimeOffset cacheExpiresAt;
private DateTimeOffset offlineExpiresAt;
internal DateTimeOffset CacheExpiresAt => cacheExpiresAt;
internal DateTimeOffset OfflineExpiresAt => offlineExpiresAt;
public StellaOpsJwksCache(
HttpClient httpClient,
StellaOpsDiscoveryCache discoveryCache,

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0080-M | DONE | Maintainability audit for StellaOps.Auth.Client. |
| AUDIT-0080-T | DONE | Test coverage audit for StellaOps.Auth.Client. |
| AUDIT-0080-A | TODO | Pending approval for changes. |
| AUDIT-0080-A | DONE | Retry options, shared cache, and coverage gaps addressed. |

View File

@@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.ServerIntegration;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsAuthorityConfigurationManagerTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetConfigurationAsync_UsesCacheUntilExpiry()
{
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var timeProvider = new FakeTimeProvider(now);
var handler = new RecordingHandler();
handler.EnqueueMetadataResponse(CreateJsonResponse(DiscoveryDocument));
handler.EnqueueJwksResponse(CreateJsonResponse("{\"keys\":[]}"));
var options = CreateOptions("https://authority.test");
var optionsMonitor = new MutableOptionsMonitor<StellaOpsResourceServerOptions>(options);
var manager = new StellaOpsAuthorityConfigurationManager(
new TestHttpClientFactory(new HttpClient(handler)),
optionsMonitor,
timeProvider,
NullLogger<StellaOpsAuthorityConfigurationManager>.Instance);
var first = await manager.GetConfigurationAsync(CancellationToken.None);
var second = await manager.GetConfigurationAsync(CancellationToken.None);
Assert.Same(first, second);
Assert.Equal(1, handler.MetadataRequests);
Assert.Equal(1, handler.JwksRequests);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetConfigurationAsync_UsesOfflineFallbackWhenRefreshFails()
{
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var timeProvider = new FakeTimeProvider(now);
var handler = new RecordingHandler();
handler.EnqueueMetadataResponse(CreateJsonResponse(DiscoveryDocument));
handler.EnqueueJwksResponse(CreateJsonResponse("{\"keys\":[]}"));
handler.EnqueueMetadataResponse(_ => throw new HttpRequestException("offline"));
var options = CreateOptions("https://authority.test");
options.MetadataCacheLifetime = TimeSpan.FromMinutes(1);
options.OfflineCacheTolerance = TimeSpan.FromMinutes(5);
options.Validate();
var optionsMonitor = new MutableOptionsMonitor<StellaOpsResourceServerOptions>(options);
var manager = new StellaOpsAuthorityConfigurationManager(
new TestHttpClientFactory(new HttpClient(handler)),
optionsMonitor,
timeProvider,
NullLogger<StellaOpsAuthorityConfigurationManager>.Instance);
var first = await manager.GetConfigurationAsync(CancellationToken.None);
timeProvider.Advance(TimeSpan.FromMinutes(2));
var second = await manager.GetConfigurationAsync(CancellationToken.None);
Assert.Same(first, second);
Assert.Equal(2, handler.MetadataRequests);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetConfigurationAsync_RefreshesWhenAuthorityChanges()
{
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var timeProvider = new FakeTimeProvider(now);
var handler = new RecordingHandler();
handler.EnqueueMetadataResponse(CreateJsonResponse(DiscoveryDocument));
handler.EnqueueJwksResponse(CreateJsonResponse("{\"keys\":[]}"));
handler.EnqueueMetadataResponse(CreateJsonResponse(DiscoveryDocument));
handler.EnqueueJwksResponse(CreateJsonResponse("{\"keys\":[]}"));
var options = CreateOptions("https://authority.test");
var optionsMonitor = new MutableOptionsMonitor<StellaOpsResourceServerOptions>(options);
var manager = new StellaOpsAuthorityConfigurationManager(
new TestHttpClientFactory(new HttpClient(handler)),
optionsMonitor,
timeProvider,
NullLogger<StellaOpsAuthorityConfigurationManager>.Instance);
await manager.GetConfigurationAsync(CancellationToken.None);
var updated = CreateOptions("https://authority2.test");
optionsMonitor.Set(updated);
await manager.GetConfigurationAsync(CancellationToken.None);
Assert.Equal(2, handler.MetadataRequests);
}
private static StellaOpsResourceServerOptions CreateOptions(string authority)
{
var options = new StellaOpsResourceServerOptions
{
Authority = authority,
MetadataCacheLifetime = TimeSpan.FromMinutes(5),
OfflineCacheTolerance = TimeSpan.FromMinutes(10),
AllowOfflineCacheFallback = true
};
options.Validate();
return options;
}
private static HttpResponseMessage CreateJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json)
{
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
}
};
}
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> metadataResponses = new();
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> jwksResponses = new();
public int MetadataRequests { get; private set; }
public int JwksRequests { get; private set; }
public void EnqueueMetadataResponse(HttpResponseMessage response)
=> metadataResponses.Enqueue(_ => response);
public void EnqueueMetadataResponse(Func<HttpRequestMessage, HttpResponseMessage> factory)
=> metadataResponses.Enqueue(factory);
public void EnqueueJwksResponse(HttpResponseMessage response)
=> jwksResponses.Enqueue(_ => response);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var uri = request.RequestUri?.AbsoluteUri ?? string.Empty;
if (uri.Contains("openid-configuration", StringComparison.OrdinalIgnoreCase))
{
MetadataRequests++;
return Task.FromResult(metadataResponses.Dequeue().Invoke(request));
}
if (uri.Contains("jwks", StringComparison.OrdinalIgnoreCase))
{
JwksRequests++;
return Task.FromResult(jwksResponses.Dequeue().Invoke(request));
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
private sealed class TestHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient client;
public TestHttpClientFactory(HttpClient client)
{
this.client = client;
}
public HttpClient CreateClient(string name) => client;
}
private sealed class MutableOptionsMonitor<T> : IOptionsMonitor<T>
where T : class
{
private T value;
public MutableOptionsMonitor(T value)
{
this.value = value;
}
public T CurrentValue => value;
public T Get(string? name) => value;
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
public void Set(T newValue) => value = newValue;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
private const string DiscoveryDocument =
"{\"issuer\":\"https://authority.test\",\"authorization_endpoint\":\"https://authority.test/connect/authorize\",\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}";
}

View File

@@ -0,0 +1,82 @@
using System.Collections.Generic;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.ServerIntegration;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsBypassEvaluatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ShouldBypass_ReturnsFalse_WhenRemoteIpMissing()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.test";
options.BypassNetworks.Add("127.0.0.1/32");
options.Validate();
});
var evaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
var context = new DefaultHttpContext();
var result = evaluator.ShouldBypass(context, new List<string> { "scope" });
Assert.False(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ShouldBypass_ReturnsFalse_WhenAuthorizationHeaderPresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.test";
options.BypassNetworks.Add("127.0.0.1/32");
options.Validate();
});
var evaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
var context = new DefaultHttpContext();
context.Connection.RemoteIpAddress = IPAddress.Parse("127.0.0.1");
context.Request.Headers["Authorization"] = "Bearer token";
var result = evaluator.ShouldBypass(context, new List<string> { "scope" });
Assert.False(result);
}
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()
{
}
}
}
}

View File

@@ -55,4 +55,19 @@ public class StellaOpsResourceServerOptionsTests
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_Throws_When_OfflineCacheToleranceInvalid()
{
var options = new StellaOpsResourceServerOptions
{
Authority = "https://authority.stella-ops.test",
OfflineCacheTolerance = TimeSpan.FromDays(2)
};
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("offline cache tolerance", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -54,6 +54,56 @@ public class StellaOpsScopeAuthorizationHandlerTests
Assert.False(string.IsNullOrWhiteSpace(record.CorrelationId));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleRequirement_Succeeds_WhenScopeItemIsNormalized()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.2"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PolicyRun });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-2")
.AddClaim(StellaOpsClaimTypes.ScopeItem, " POLICY:RUN ")
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
Assert.Single(sink.Records);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleRequirement_Succeeds_WhenScopeItemVulnReadMapsToVulnView()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.3"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.VulnView });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-3")
.AddClaim(StellaOpsClaimTypes.ScopeItem, "VULN:READ")
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
Assert.Single(sink.Records);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleRequirement_Fails_WhenTenantMismatch()

View File

@@ -5,7 +5,7 @@
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<PackageId>StellaOps.Auth.ServerIntegration</PackageId>

View File

@@ -25,6 +25,9 @@ internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationMan
private OpenIdConnectConfiguration? cachedConfiguration;
private DateTimeOffset cacheExpiresAt;
private DateTimeOffset offlineExpiresAt;
private string? cachedMetadataAddress;
private Uri? cachedAuthorityUri;
public StellaOpsAuthorityConfigurationManager(
IHttpClientFactory httpClientFactory,
@@ -40,6 +43,15 @@ internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationMan
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue;
var metadataAddress = ResolveMetadataAddress(options);
if (OptionsChanged(options, metadataAddress))
{
cachedAuthorityUri = options.AuthorityUri;
cachedMetadataAddress = metadataAddress;
RequestRefresh();
}
var now = timeProvider.GetUtcNow();
var current = Volatile.Read(ref cachedConfiguration);
if (current is not null && now < cacheExpiresAt)
@@ -55,8 +67,6 @@ internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationMan
return cachedConfiguration;
}
var options = optionsMonitor.CurrentValue;
var metadataAddress = ResolveMetadataAddress(options);
var httpClient = httpClientFactory.CreateClient(HttpClientName);
httpClient.Timeout = options.BackchannelTimeout;
@@ -67,24 +77,32 @@ internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationMan
logger.LogDebug("Fetching OpenID Connect configuration from {MetadataAddress}.", metadataAddress);
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
configuration.Issuer ??= options.AuthorityUri.ToString();
if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
try
{
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
configuration.SigningKeys.Clear();
foreach (JsonWebKey key in jsonWebKeySet.Keys)
{
configuration.SigningKeys.Add(key);
}
}
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
configuration.Issuer ??= options.AuthorityUri.ToString();
cachedConfiguration = configuration;
cacheExpiresAt = now + options.MetadataCacheLifetime;
return configuration;
if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
{
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
configuration.SigningKeys.Clear();
foreach (JsonWebKey key in jsonWebKeySet.Keys)
{
configuration.SigningKeys.Add(key);
}
}
cachedConfiguration = configuration;
cacheExpiresAt = now + options.MetadataCacheLifetime;
offlineExpiresAt = cacheExpiresAt + options.OfflineCacheTolerance;
return configuration;
}
catch (Exception exception) when (IsOfflineCandidate(exception, cancellationToken) && TryUseOfflineFallback(options, now, exception))
{
return cachedConfiguration!;
}
}
finally
{
@@ -96,6 +114,7 @@ internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationMan
{
Volatile.Write(ref cachedConfiguration, null);
cacheExpiresAt = DateTimeOffset.MinValue;
offlineExpiresAt = DateTimeOffset.MinValue;
}
private static string ResolveMetadataAddress(StellaOpsResourceServerOptions options)
@@ -113,4 +132,70 @@ internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationMan
return new Uri(authority, ".well-known/openid-configuration").AbsoluteUri;
}
private bool OptionsChanged(StellaOpsResourceServerOptions options, string metadataAddress)
{
if (cachedAuthorityUri is null || cachedMetadataAddress is null)
{
return true;
}
if (!string.Equals(cachedMetadataAddress, metadataAddress, StringComparison.Ordinal))
{
return true;
}
if (!Uri.Equals(cachedAuthorityUri, options.AuthorityUri))
{
return true;
}
return false;
}
private static bool IsOfflineCandidate(Exception exception, CancellationToken cancellationToken)
{
if (exception is HttpRequestException)
{
return true;
}
if (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
return true;
}
if (exception is TimeoutException)
{
return true;
}
return false;
}
private bool TryUseOfflineFallback(StellaOpsResourceServerOptions options, DateTimeOffset now, Exception exception)
{
if (!options.AllowOfflineCacheFallback || cachedConfiguration is null)
{
return false;
}
if (options.OfflineCacheTolerance <= TimeSpan.Zero)
{
return false;
}
if (offlineExpiresAt == DateTimeOffset.MinValue)
{
return false;
}
if (now >= offlineExpiresAt)
{
return false;
}
logger.LogWarning(exception, "Authority metadata refresh failed; reusing cached configuration until {FallbackExpiresAt}.", offlineExpiresAt);
return true;
}
}

View File

@@ -65,6 +65,16 @@ public sealed class StellaOpsResourceServerOptions
/// </summary>
public TimeSpan MetadataCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets a value indicating whether stale metadata/JWKS may be reused if Authority is unreachable.
/// </summary>
public bool AllowOfflineCacheFallback { get; set; } = true;
/// <summary>
/// Additional tolerance window during which stale metadata/JWKS may be reused when offline fallback is allowed.
/// </summary>
public TimeSpan OfflineCacheTolerance { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// Gets the canonical Authority URI (populated during validation).
/// </summary>
@@ -122,6 +132,11 @@ public sealed class StellaOpsResourceServerOptions
throw new InvalidOperationException("Resource server metadata cache lifetime must be greater than zero and less than or equal to 24 hours.");
}
if (OfflineCacheTolerance < TimeSpan.Zero || OfflineCacheTolerance > TimeSpan.FromHours(24))
{
throw new InvalidOperationException("Resource server offline cache tolerance must be between 0 and 24 hours.");
}
AuthorityUri = authorityUri;
NormalizeList(audiences, toLower: false);

View File

@@ -1011,7 +1011,11 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
continue;
}
scopes.Add(claim.Value);
var normalized = StellaOpsScopes.Normalize(claim.Value);
if (normalized is not null)
{
scopes.Add(normalized);
}
}
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0083-M | DONE | Maintainability audit for StellaOps.Auth.ServerIntegration. |
| AUDIT-0083-T | DONE | Test coverage audit for StellaOps.Auth.ServerIntegration. |
| AUDIT-0083-A | TODO | Pending approval for changes. |
| AUDIT-0083-A | DONE | Metadata fallback, scope normalization, and coverage gaps addressed. |

View File

@@ -13,6 +13,7 @@ using Microsoft.Extensions.Options;
using StellaOps.Authority.Notifications;
using StellaOps.Authority.Notifications.Ack;
using StellaOps.Authority.Signing;
using StellaOps.Authority.Storage;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
@@ -157,6 +158,7 @@ public sealed class AuthorityAckTokenIssuerTests
services.AddSingleton(options);
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityIdGenerator, GuidAuthorityIdGenerator>();
services.AddMemoryCache();
services.AddStellaOpsCrypto();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>());

View File

@@ -16,6 +16,7 @@ using Microsoft.Extensions.Time.Testing;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Authority.Permalinks;
using StellaOps.Authority.Signing;
using StellaOps.Authority.Storage;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
@@ -119,6 +120,7 @@ public sealed class VulnPermalinkServiceTests
services.AddSingleton(options);
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
services.AddSingleton(timeProvider);
services.AddSingleton<IAuthorityIdGenerator, GuidAuthorityIdGenerator>();
services.AddMemoryCache();
services.AddStellaOpsCrypto();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>());

View File

@@ -0,0 +1,426 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Authority.Persistence.Documents;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Persistence.PostgresAdapters;
using StellaOps.Authority.Storage;
using Xunit;
namespace StellaOps.Authority.Tests.Storage;
public sealed class PostgresAdapterTests
{
[Fact]
public async Task ClientStore_UsesIdAndClockDefaults()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-03T10:00:00Z"));
var repository = new TestClientRepository();
var store = new PostgresClientStore(repository, clock, new FixedAuthorityIdGenerator("client-1"));
var document = new AuthorityClientDocument
{
Id = string.Empty,
ClientId = "client-a",
CreatedAt = default,
UpdatedAt = default
};
await store.UpsertAsync(document, CancellationToken.None);
Assert.NotNull(repository.LastUpsert);
Assert.Equal("client-1", repository.LastUpsert!.Id);
Assert.Equal(clock.GetUtcNow(), repository.LastUpsert.CreatedAt);
Assert.Equal(clock.GetUtcNow(), repository.LastUpsert.UpdatedAt);
}
[Fact]
public async Task ServiceAccountStore_UsesIdAndClockDefaults()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-03T10:15:00Z"));
var repository = new TestServiceAccountRepository();
var store = new PostgresServiceAccountStore(repository, clock, new FixedAuthorityIdGenerator("svc-1"));
var document = new AuthorityServiceAccountDocument
{
Id = string.Empty,
AccountId = "svc-account",
Tenant = "tenant-a",
CreatedAt = default,
UpdatedAt = default
};
await store.UpsertAsync(document, CancellationToken.None);
Assert.NotNull(repository.LastUpsert);
Assert.Equal("svc-1", repository.LastUpsert!.Id);
Assert.Equal(clock.GetUtcNow(), repository.LastUpsert.CreatedAt);
Assert.Equal(clock.GetUtcNow(), repository.LastUpsert.UpdatedAt);
}
[Fact]
public async Task LoginAttemptStore_UsesIdGenerator()
{
var repository = new TestLoginAttemptRepository();
var store = new PostgresLoginAttemptStore(repository, new FixedAuthorityIdGenerator("login-1"));
var document = new AuthorityLoginAttemptDocument
{
Id = string.Empty,
EventType = "login",
Outcome = "success",
OccurredAt = DateTimeOffset.Parse("2025-11-03T11:00:00Z")
};
await store.InsertAsync(document, CancellationToken.None);
Assert.NotNull(repository.LastInsert);
Assert.Equal("login-1", repository.LastInsert!.Id);
}
[Fact]
public async Task RevocationStore_UsesIdGenerator()
{
var repository = new TestRevocationRepository();
var store = new PostgresRevocationStore(repository, new FixedAuthorityIdGenerator("revoke-1"));
var document = new AuthorityRevocationDocument
{
Id = string.Empty,
Category = "token",
RevocationId = "rev-1",
SubjectId = "user-1",
Reason = "test",
RevokedAt = DateTimeOffset.Parse("2025-11-03T11:30:00Z")
};
await store.UpsertAsync(document, CancellationToken.None);
Assert.NotNull(repository.LastUpsert);
Assert.Equal("revoke-1", repository.LastUpsert!.Id);
}
[Fact]
public async Task AirgapAuditStore_UsesIdGenerator()
{
var repository = new TestAirgapAuditRepository();
var store = new PostgresAirgapAuditStore(repository, new FixedAuthorityIdGenerator("audit-1"));
var document = new AuthorityAirgapAuditDocument
{
Id = string.Empty,
EventType = "audit",
Outcome = "ok",
OccurredAt = DateTimeOffset.Parse("2025-11-03T12:00:00Z")
};
await store.InsertAsync(document, CancellationToken.None);
Assert.NotNull(repository.LastInsert);
Assert.Equal("audit-1", repository.LastInsert!.Id);
}
[Fact]
public async Task TokenStore_UsesIdAndClockDefaults()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-03T12:30:00Z"));
var repository = new TestOidcTokenRepository();
var store = new PostgresTokenStore(repository, clock, new FixedAuthorityIdGenerator("tok-1"));
var document = new AuthorityTokenDocument
{
Id = string.Empty,
TokenId = "token-1",
TokenType = "access_token",
CreatedAt = default
};
await store.UpsertAsync(document, CancellationToken.None);
var upserted = Assert.Single(repository.UpsertedTokens);
Assert.Equal("tok-1", upserted.Id);
Assert.Equal(clock.GetUtcNow(), upserted.CreatedAt);
}
[Fact]
public async Task TokenStore_UsesIdAndClockDefaults_ForRefreshTokens()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-03T12:45:00Z"));
var repository = new TestOidcTokenRepository();
var store = new PostgresTokenStore(repository, clock, new FixedAuthorityIdGenerator("refresh-1"));
var document = new AuthorityRefreshTokenDocument
{
Id = string.Empty,
TokenId = "refresh-1",
ClientId = "client-1",
CreatedAt = default
};
await store.UpsertAsync(document, CancellationToken.None);
var upserted = Assert.Single(repository.UpsertedRefreshTokens);
Assert.Equal("refresh-1", upserted.Id);
Assert.Equal(clock.GetUtcNow(), upserted.CreatedAt);
}
[Fact]
public async Task TokenStore_RecordUsageAsync_ExpiresOldFingerprints()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-03T13:00:00Z"));
var store = new PostgresTokenStore(new TestOidcTokenRepository(), clock, new FixedAuthorityIdGenerator("tok-1"));
var first = await store.RecordUsageAsync("token-1", "10.0.0.1", "agent-1", clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(TokenUsageUpdateStatus.Recorded, first.Status);
clock.Advance(TimeSpan.FromMinutes(1));
var second = await store.RecordUsageAsync("token-1", "10.0.0.2", "agent-2", clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(TokenUsageUpdateStatus.SuspectedReplay, second.Status);
clock.Advance(TimeSpan.FromHours(7));
var third = await store.RecordUsageAsync("token-1", "10.0.0.3", "agent-3", clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(TokenUsageUpdateStatus.Recorded, third.Status);
}
private sealed class FixedAuthorityIdGenerator : IAuthorityIdGenerator
{
private readonly string value;
public FixedAuthorityIdGenerator(string value)
{
this.value = value;
}
public string NextId() => value;
}
private sealed class TestClientRepository : IClientRepository
{
public ClientEntity? LastUpsert { get; private set; }
public Task<ClientEntity?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken = default)
=> Task.FromResult<ClientEntity?>(LastUpsert);
public Task UpsertAsync(ClientEntity entity, CancellationToken cancellationToken = default)
{
LastUpsert = entity;
return Task.CompletedTask;
}
public Task<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
private sealed class TestServiceAccountRepository : IServiceAccountRepository
{
public ServiceAccountEntity? LastUpsert { get; private set; }
public Task<ServiceAccountEntity?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken = default)
=> Task.FromResult<ServiceAccountEntity?>(LastUpsert);
public Task<IReadOnlyList<ServiceAccountEntity>> ListAsync(string? tenant, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ServiceAccountEntity>>(LastUpsert is null ? Array.Empty<ServiceAccountEntity>() : new[] { LastUpsert });
public Task UpsertAsync(ServiceAccountEntity entity, CancellationToken cancellationToken = default)
{
LastUpsert = entity;
return Task.CompletedTask;
}
public Task<bool> DeleteAsync(string accountId, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
private sealed class TestLoginAttemptRepository : ILoginAttemptRepository
{
public LoginAttemptEntity? LastInsert { get; private set; }
public Task InsertAsync(LoginAttemptEntity entity, CancellationToken cancellationToken = default)
{
LastInsert = entity;
return Task.CompletedTask;
}
public Task<IReadOnlyList<LoginAttemptEntity>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<LoginAttemptEntity>>(LastInsert is null ? Array.Empty<LoginAttemptEntity>() : new[] { LastInsert });
}
private sealed class TestRevocationRepository : IRevocationRepository
{
public RevocationEntity? LastUpsert { get; private set; }
public Task UpsertAsync(RevocationEntity entity, CancellationToken cancellationToken = default)
{
LastUpsert = entity;
return Task.CompletedTask;
}
public Task<IReadOnlyList<RevocationEntity>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<RevocationEntity>>(LastUpsert is null ? Array.Empty<RevocationEntity>() : new[] { LastUpsert });
public Task RemoveAsync(string category, string revocationId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
private sealed class TestAirgapAuditRepository : IAirgapAuditRepository
{
public AirgapAuditEntity? LastInsert { get; private set; }
public Task InsertAsync(AirgapAuditEntity entity, CancellationToken cancellationToken = default)
{
LastInsert = entity;
return Task.CompletedTask;
}
public Task<IReadOnlyList<AirgapAuditEntity>> ListAsync(int limit, int offset, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<AirgapAuditEntity>>(LastInsert is null ? Array.Empty<AirgapAuditEntity>() : new[] { LastInsert });
}
private sealed class TestOidcTokenRepository : IOidcTokenRepository
{
public List<OidcTokenEntity> Tokens { get; } = new();
public List<OidcRefreshTokenEntity> RefreshTokens { get; } = new();
public List<OidcTokenEntity> UpsertedTokens { get; } = new();
public List<OidcRefreshTokenEntity> UpsertedRefreshTokens { get; } = new();
public Task<OidcTokenEntity?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken = default)
=> Task.FromResult(Tokens.FirstOrDefault(token => token.TokenId == tokenId));
public Task<OidcTokenEntity?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken = default)
=> Task.FromResult(Tokens.FirstOrDefault(token => token.ReferenceId == referenceId));
public Task<IReadOnlyList<OidcTokenEntity>> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<OidcTokenEntity>>(Tokens.Where(token => token.SubjectId == subjectId).Take(limit).ToArray());
public Task<IReadOnlyList<OidcTokenEntity>> ListByClientAsync(string clientId, int limit, int offset, CancellationToken cancellationToken = default)
{
var page = Tokens
.Where(token => token.ClientId == clientId)
.OrderByDescending(token => token.CreatedAt)
.ThenByDescending(token => token.Id, StringComparer.Ordinal)
.Skip(offset)
.Take(limit)
.ToArray();
return Task.FromResult<IReadOnlyList<OidcTokenEntity>>(page);
}
public Task<IReadOnlyList<OidcTokenEntity>> ListByScopeAsync(string tenant, string scope, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken = default)
{
var results = Tokens.Where(token =>
{
if (!token.Properties.TryGetValue("tenant", out var storedTenant) || !string.Equals(storedTenant, tenant, StringComparison.Ordinal))
{
return false;
}
if (issuedAfter is not null && token.CreatedAt < issuedAfter.Value)
{
return false;
}
return token.Properties.TryGetValue("scope", out var scopeValue)
&& scopeValue.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Contains(scope, StringComparer.Ordinal);
}).Take(limit).ToArray();
return Task.FromResult<IReadOnlyList<OidcTokenEntity>>(results);
}
public Task<IReadOnlyList<OidcTokenEntity>> ListRevokedAsync(string? tenant, int limit, CancellationToken cancellationToken = default)
{
var results = Tokens
.Where(token => token.Properties.TryGetValue("status", out var status)
&& string.Equals(status, "revoked", StringComparison.OrdinalIgnoreCase))
.Where(token => string.IsNullOrWhiteSpace(tenant)
|| (token.Properties.TryGetValue("tenant", out var storedTenant)
&& string.Equals(storedTenant, tenant, StringComparison.Ordinal)))
.Take(limit)
.ToArray();
return Task.FromResult<IReadOnlyList<OidcTokenEntity>>(results);
}
public Task<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, CancellationToken cancellationToken = default)
{
var count = Tokens.LongCount(token =>
token.Properties.TryGetValue("tenant", out var storedTenant)
&& string.Equals(storedTenant, tenant, StringComparison.Ordinal)
&& (!token.Properties.TryGetValue("status", out var status) || !string.Equals(status, "revoked", StringComparison.OrdinalIgnoreCase))
&& (token.ExpiresAt is null || token.ExpiresAt > now)
&& (string.IsNullOrWhiteSpace(serviceAccountId)
|| (token.Properties.TryGetValue("service_account_id", out var storedService) && string.Equals(storedService, serviceAccountId, StringComparison.Ordinal))));
return Task.FromResult(count);
}
public Task<IReadOnlyList<OidcTokenEntity>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, int limit, CancellationToken cancellationToken = default)
{
var results = Tokens.Where(token =>
token.Properties.TryGetValue("tenant", out var storedTenant)
&& string.Equals(storedTenant, tenant, StringComparison.Ordinal)
&& (!token.Properties.TryGetValue("status", out var status) || !string.Equals(status, "revoked", StringComparison.OrdinalIgnoreCase))
&& (token.ExpiresAt is null || token.ExpiresAt > now)
&& (string.IsNullOrWhiteSpace(serviceAccountId)
|| (token.Properties.TryGetValue("service_account_id", out var storedService) && string.Equals(storedService, serviceAccountId, StringComparison.Ordinal))))
.Take(limit)
.ToArray();
return Task.FromResult<IReadOnlyList<OidcTokenEntity>>(results);
}
public Task<IReadOnlyList<OidcTokenEntity>> ListAsync(int limit, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<OidcTokenEntity>>(Tokens.Take(limit).ToArray());
public Task UpsertAsync(OidcTokenEntity entity, CancellationToken cancellationToken = default)
{
UpsertedTokens.Add(entity);
Tokens.RemoveAll(token => token.TokenId == entity.TokenId);
Tokens.Add(entity);
return Task.CompletedTask;
}
public Task<bool> RevokeAsync(string tokenId, CancellationToken cancellationToken = default)
{
Tokens.RemoveAll(token => token.TokenId == tokenId);
return Task.FromResult(true);
}
public Task<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken = default)
{
var count = Tokens.RemoveAll(token => token.SubjectId == subjectId);
return Task.FromResult(count);
}
public Task<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken = default)
{
var count = Tokens.RemoveAll(token => token.ClientId == clientId);
return Task.FromResult(count);
}
public Task<OidcRefreshTokenEntity?> FindRefreshTokenAsync(string tokenId, CancellationToken cancellationToken = default)
=> Task.FromResult(RefreshTokens.FirstOrDefault(token => token.TokenId == tokenId));
public Task<OidcRefreshTokenEntity?> FindRefreshTokenByHandleAsync(string handle, CancellationToken cancellationToken = default)
=> Task.FromResult(RefreshTokens.FirstOrDefault(token => token.Handle == handle));
public Task UpsertRefreshTokenAsync(OidcRefreshTokenEntity entity, CancellationToken cancellationToken = default)
{
UpsertedRefreshTokens.Add(entity);
RefreshTokens.RemoveAll(token => token.TokenId == entity.TokenId);
RefreshTokens.Add(entity);
return Task.CompletedTask;
}
public Task<bool> ConsumeRefreshTokenAsync(string tokenId, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
public Task<int> RevokeRefreshTokensBySubjectAsync(string subjectId, CancellationToken cancellationToken = default)
{
var count = RefreshTokens.RemoveAll(token => token.SubjectId == subjectId);
return Task.FromResult(count);
}
}
}

View File

@@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Storage;
using StellaOps.Authority.Vulnerability.Attachments;
using StellaOps.Authority.Vulnerability.Workflow;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Authority.Tests.Vulnerability;
public sealed class VulnTokenIssuerTests
{
[Fact]
public async Task WorkflowIssuer_UsesIdGeneratorForNonceAndTokenId()
{
var options = BuildOptions();
var registry = new TestCryptoProviderRegistry();
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-03T14:00:00Z"));
var idGenerator = new SequenceAuthorityIdGenerator("nonce-1", "token-1");
var issuer = new VulnWorkflowAntiForgeryTokenIssuer(
registry,
Options.Create(options),
clock,
idGenerator,
NullLogger<VulnWorkflowAntiForgeryTokenIssuer>.Instance);
var principal = BuildPrincipal();
var request = new VulnWorkflowAntiForgeryIssueRequest
{
Actions = new[] { "assign" }
};
var result = await issuer.IssueAsync(principal, request, CancellationToken.None);
Assert.Equal("nonce-1", result.Payload.Nonce);
Assert.Equal("token-1", result.Payload.TokenId);
}
[Fact]
public async Task WorkflowIssuer_ThrowsWhenNonceTooShort()
{
var options = BuildOptions();
var registry = new TestCryptoProviderRegistry();
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-03T14:10:00Z"));
var idGenerator = new SequenceAuthorityIdGenerator("nonce-1");
var issuer = new VulnWorkflowAntiForgeryTokenIssuer(
registry,
Options.Create(options),
clock,
idGenerator,
NullLogger<VulnWorkflowAntiForgeryTokenIssuer>.Instance);
var principal = BuildPrincipal();
var request = new VulnWorkflowAntiForgeryIssueRequest
{
Actions = new[] { "assign" },
Nonce = "short"
};
await Assert.ThrowsAsync<InvalidOperationException>(() => issuer.IssueAsync(principal, request, CancellationToken.None));
}
[Fact]
public async Task AttachmentIssuer_RequiresLedgerEventHash()
{
var options = BuildOptions();
var registry = new TestCryptoProviderRegistry();
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-03T14:20:00Z"));
var idGenerator = new SequenceAuthorityIdGenerator("token-1");
var issuer = new VulnAttachmentTokenIssuer(
registry,
Options.Create(options),
clock,
idGenerator,
NullLogger<VulnAttachmentTokenIssuer>.Instance);
var principal = BuildPrincipal();
var request = new VulnAttachmentTokenIssueRequest
{
LedgerEventHash = string.Empty,
AttachmentId = "attachment-1"
};
await Assert.ThrowsAsync<InvalidOperationException>(() => issuer.IssueAsync(principal, request, CancellationToken.None));
}
[Fact]
public async Task AttachmentIssuer_UsesIdGeneratorForTokenId()
{
var options = BuildOptions();
var registry = new TestCryptoProviderRegistry();
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-03T14:30:00Z"));
var idGenerator = new SequenceAuthorityIdGenerator("token-2");
var issuer = new VulnAttachmentTokenIssuer(
registry,
Options.Create(options),
clock,
idGenerator,
NullLogger<VulnAttachmentTokenIssuer>.Instance);
var principal = BuildPrincipal();
var request = new VulnAttachmentTokenIssueRequest
{
LedgerEventHash = "ledger-1",
AttachmentId = "attachment-1"
};
var result = await issuer.IssueAsync(principal, request, CancellationToken.None);
Assert.Equal("token-2", result.Payload.TokenId);
}
private static StellaOpsAuthorityOptions BuildOptions()
{
return new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test"),
Signing =
{
Enabled = true,
ActiveKeyId = "key-1",
Algorithm = SignatureAlgorithms.Es256,
Provider = "test"
},
VulnerabilityExplorer =
{
Workflow =
{
AntiForgery =
{
Enabled = true,
DefaultLifetime = TimeSpan.FromMinutes(5),
MaxLifetime = TimeSpan.FromMinutes(10)
}
},
Attachments =
{
Enabled = true,
DefaultLifetime = TimeSpan.FromMinutes(30),
MaxLifetime = TimeSpan.FromHours(2)
}
}
};
}
private static ClaimsPrincipal BuildPrincipal()
{
var identity = new ClaimsIdentity("test");
identity.AddClaim(new Claim(StellaOpsClaimTypes.Subject, "user-1"));
identity.AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-a"));
return new ClaimsPrincipal(identity);
}
private sealed class SequenceAuthorityIdGenerator : IAuthorityIdGenerator
{
private readonly Queue<string> values;
public SequenceAuthorityIdGenerator(params string[] values)
{
this.values = new Queue<string>(values);
}
public string NextId()
{
if (values.Count == 0)
{
throw new InvalidOperationException("No more IDs configured.");
}
return values.Dequeue();
}
}
private sealed class TestCryptoProviderRegistry : ICryptoProviderRegistry
{
public IReadOnlyCollection<ICryptoProvider> Providers => Array.Empty<ICryptoProvider>();
public bool TryResolve(string preferredProvider, out ICryptoProvider provider)
{
provider = null!;
return false;
}
public ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId)
=> throw new NotSupportedException();
public CryptoSignerResolution ResolveSigner(
CryptoCapability capability,
string algorithmId,
CryptoKeyReference keyReference,
string? preferredProvider = null)
{
return new CryptoSignerResolution(new TestSigner(keyReference.KeyId, algorithmId), "test");
}
public CryptoHasherResolution ResolveHasher(string algorithmId, string? preferredProvider = null)
=> new CryptoHasherResolution(new TestHasher(algorithmId), "test");
}
private sealed class TestSigner : ICryptoSigner
{
public TestSigner(string keyId, string algorithmId)
{
KeyId = keyId;
AlgorithmId = algorithmId;
}
public string KeyId { get; }
public string AlgorithmId { get; }
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(Array.Empty<byte>());
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(true);
public JsonWebKey ExportPublicJsonWebKey()
=> new() { Kid = KeyId, Alg = AlgorithmId };
}
private sealed class TestHasher : ICryptoHasher
{
public TestHasher(string algorithmId)
{
AlgorithmId = algorithmId;
}
public string AlgorithmId { get; }
public byte[] ComputeHash(ReadOnlySpan<byte> data) => Array.Empty<byte>();
public string ComputeHashHex(ReadOnlySpan<byte> data) => string.Empty;
}
}

View File

@@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Configuration;
using StellaOps.Authority.Storage;
namespace StellaOps.Authority.Notifications.Ack;
@@ -17,6 +18,7 @@ internal sealed class AuthorityAckTokenIssuer
private readonly AuthorityWebhookAllowlistEvaluator allowlistEvaluator;
private readonly StellaOpsAuthorityOptions authorityOptions;
private readonly TimeProvider timeProvider;
private readonly IAuthorityIdGenerator idGenerator;
private readonly ILogger<AuthorityAckTokenIssuer> logger;
public AuthorityAckTokenIssuer(
@@ -24,12 +26,14 @@ internal sealed class AuthorityAckTokenIssuer
AuthorityWebhookAllowlistEvaluator allowlistEvaluator,
IOptions<StellaOpsAuthorityOptions> authorityOptions,
TimeProvider timeProvider,
IAuthorityIdGenerator idGenerator,
ILogger<AuthorityAckTokenIssuer> logger)
{
this.keyManager = keyManager ?? throw new ArgumentNullException(nameof(keyManager));
this.allowlistEvaluator = allowlistEvaluator ?? throw new ArgumentNullException(nameof(allowlistEvaluator));
this.authorityOptions = authorityOptions?.Value ?? throw new ArgumentNullException(nameof(authorityOptions));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -54,7 +58,7 @@ internal sealed class AuthorityAckTokenIssuer
var channel = Require(request.Channel, nameof(request.Channel));
var webhookUrl = Require(request.WebhookUrl, nameof(request.WebhookUrl));
var normalizedNonce = string.IsNullOrWhiteSpace(request.Nonce)
? Guid.NewGuid().ToString("N")
? idGenerator.NextId()
: request.Nonce!.Trim();
if (!Uri.TryCreate(webhookUrl, UriKind.Absolute, out var webhookUri))

View File

@@ -11,6 +11,7 @@ using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Abstractions;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using StellaOps.Authority.Storage;
namespace StellaOps.Authority.Permalinks;
@@ -36,17 +37,20 @@ internal sealed class VulnPermalinkService
private readonly ICryptoProviderRegistry providerRegistry;
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptions;
private readonly TimeProvider timeProvider;
private readonly IAuthorityIdGenerator idGenerator;
private readonly ILogger<VulnPermalinkService> logger;
public VulnPermalinkService(
ICryptoProviderRegistry providerRegistry,
IOptions<StellaOpsAuthorityOptions> authorityOptions,
TimeProvider timeProvider,
IAuthorityIdGenerator idGenerator,
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.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -141,7 +145,7 @@ internal sealed class VulnPermalinkService
IssuedAt: issuedAt.ToUnixTimeSeconds(),
NotBefore: issuedAt.ToUnixTimeSeconds(),
ExpiresAt: expiresAt.ToUnixTimeSeconds(),
TokenId: Guid.NewGuid().ToString("N"),
TokenId: idGenerator.NextId(),
Resource: new VulnPermalinkResource(resourceKind, stateElement));
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload, PayloadSerializerOptions);

View File

@@ -38,6 +38,7 @@ using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Sessions;
using StellaOps.Authority.Persistence.Postgres;
using StellaOps.Authority.Persistence.PostgresAdapters;
using StellaOps.Authority.Storage;
using StellaOps.Authority.RateLimiting;
using StellaOps.Configuration;
using StellaOps.Plugin.DependencyInjection;
@@ -138,6 +139,7 @@ builder.Services.AddSingleton(authorityOptions);
builder.Services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(authorityOptions));
builder.Services.AddHttpContextAccessor();
builder.Services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
builder.Services.TryAddSingleton<IAuthorityIdGenerator, GuidAuthorityIdGenerator>();
builder.Services.AddMemoryCache();
builder.Services.TryAddSingleton<IAuthorityRateLimiterMetadataAccessor, AuthorityRateLimiterMetadataAccessor>();
builder.Services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>();

View File

@@ -5,7 +5,7 @@
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DefineConstants>$(DefineConstants);STELLAOPS_AUTH_SECURITY</DefineConstants>
</PropertyGroup>
<ItemGroup>

View File

@@ -0,0 +1,11 @@
namespace StellaOps.Authority.Storage;
internal interface IAuthorityIdGenerator
{
string NextId();
}
internal sealed class GuidAuthorityIdGenerator : IAuthorityIdGenerator
{
public string NextId() => Guid.NewGuid().ToString("N");
}

View File

@@ -3,6 +3,7 @@ using StellaOps.Authority.Persistence.Sessions;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Storage;
namespace StellaOps.Authority.Persistence.PostgresAdapters;
@@ -11,11 +12,15 @@ namespace StellaOps.Authority.Persistence.PostgresAdapters;
/// </summary>
internal sealed class PostgresAirgapAuditStore : IAuthorityAirgapAuditStore
{
private readonly AirgapAuditRepository repository;
private readonly IAirgapAuditRepository repository;
private readonly IAuthorityIdGenerator idGenerator;
public PostgresAirgapAuditStore(AirgapAuditRepository repository)
public PostgresAirgapAuditStore(
IAirgapAuditRepository repository,
IAuthorityIdGenerator idGenerator)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public async ValueTask InsertAsync(AuthorityAirgapAuditDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
@@ -45,7 +50,7 @@ internal sealed class PostgresAirgapAuditStore : IAuthorityAirgapAuditStore
var entity = new AirgapAuditEntity
{
Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id,
Id = string.IsNullOrWhiteSpace(document.Id) ? idGenerator.NextId() : document.Id,
EventType = string.IsNullOrWhiteSpace(document.EventType) ? "audit" : document.EventType,
OperatorId = document.OperatorId,
ComponentId = document.ComponentId,

View File

@@ -3,6 +3,7 @@ using StellaOps.Authority.Persistence.Sessions;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Storage;
namespace StellaOps.Authority.Persistence.PostgresAdapters;
@@ -11,11 +12,18 @@ namespace StellaOps.Authority.Persistence.PostgresAdapters;
/// </summary>
internal sealed class PostgresBootstrapInviteStore : IAuthorityBootstrapInviteStore
{
private readonly BootstrapInviteRepository repository;
private readonly IBootstrapInviteRepository repository;
private readonly TimeProvider timeProvider;
private readonly IAuthorityIdGenerator idGenerator;
public PostgresBootstrapInviteStore(BootstrapInviteRepository repository)
public PostgresBootstrapInviteStore(
IBootstrapInviteRepository repository,
TimeProvider timeProvider,
IAuthorityIdGenerator idGenerator)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public async ValueTask<AuthorityBootstrapInviteDocument?> FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
@@ -26,16 +34,17 @@ internal sealed class PostgresBootstrapInviteStore : IAuthorityBootstrapInviteSt
public async ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var createdAt = document.CreatedAt == default ? timeProvider.GetUtcNow() : document.CreatedAt;
var entity = new BootstrapInviteEntity
{
Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id,
Id = string.IsNullOrWhiteSpace(document.Id) ? idGenerator.NextId() : document.Id,
Token = document.Token,
Type = document.Type,
Provider = document.Provider,
Target = document.Target,
ExpiresAt = document.ExpiresAt,
CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt,
IssuedAt = document.IssuedAt == default ? document.CreatedAt : document.IssuedAt,
CreatedAt = createdAt,
IssuedAt = document.IssuedAt == default ? createdAt : document.IssuedAt,
IssuedBy = document.IssuedBy,
ReservedUntil = document.ReservedUntil,
ReservedBy = document.ReservedBy,

View File

@@ -3,6 +3,7 @@ using StellaOps.Authority.Persistence.Sessions;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Storage;
namespace StellaOps.Authority.Persistence.PostgresAdapters;
@@ -11,11 +12,18 @@ namespace StellaOps.Authority.Persistence.PostgresAdapters;
/// </summary>
internal sealed class PostgresClientStore : IAuthorityClientStore
{
private readonly ClientRepository repository;
private readonly IClientRepository repository;
private readonly TimeProvider timeProvider;
private readonly IAuthorityIdGenerator idGenerator;
public PostgresClientStore(ClientRepository repository)
public PostgresClientStore(
IClientRepository repository,
TimeProvider timeProvider,
IAuthorityIdGenerator idGenerator)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
@@ -26,9 +34,10 @@ internal sealed class PostgresClientStore : IAuthorityClientStore
public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var now = timeProvider.GetUtcNow();
var entity = new ClientEntity
{
Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id,
Id = string.IsNullOrWhiteSpace(document.Id) ? idGenerator.NextId() : document.Id,
ClientId = document.ClientId,
ClientSecret = document.ClientSecret,
SecretHash = document.SecretHash,
@@ -47,8 +56,8 @@ internal sealed class PostgresClientStore : IAuthorityClientStore
ClientType = document.ClientType,
Properties = document.Properties,
CertificateBindings = document.CertificateBindings.Select(MapBinding).ToArray(),
CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt,
UpdatedAt = document.UpdatedAt == default ? DateTimeOffset.UtcNow : document.UpdatedAt
CreatedAt = document.CreatedAt == default ? now : document.CreatedAt,
UpdatedAt = document.UpdatedAt == default ? now : document.UpdatedAt
};
await repository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false);

View File

@@ -4,6 +4,7 @@ using StellaOps.Authority.Persistence.Sessions;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Storage;
namespace StellaOps.Authority.Persistence.PostgresAdapters;
@@ -12,11 +13,15 @@ namespace StellaOps.Authority.Persistence.PostgresAdapters;
/// </summary>
internal sealed class PostgresLoginAttemptStore : IAuthorityLoginAttemptStore
{
private readonly LoginAttemptRepository repository;
private readonly ILoginAttemptRepository repository;
private readonly IAuthorityIdGenerator idGenerator;
public PostgresLoginAttemptStore(LoginAttemptRepository repository)
public PostgresLoginAttemptStore(
ILoginAttemptRepository repository,
IAuthorityIdGenerator idGenerator)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
@@ -58,7 +63,7 @@ internal sealed class PostgresLoginAttemptStore : IAuthorityLoginAttemptStore
var entity = new LoginAttemptEntity
{
Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id,
Id = string.IsNullOrWhiteSpace(document.Id) ? idGenerator.NextId() : document.Id,
SubjectId = document.SubjectId,
ClientId = document.ClientId,
EventType = document.EventType,

View File

@@ -3,6 +3,7 @@ using StellaOps.Authority.Persistence.Sessions;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Storage;
namespace StellaOps.Authority.Persistence.PostgresAdapters;
@@ -11,18 +12,22 @@ namespace StellaOps.Authority.Persistence.PostgresAdapters;
/// </summary>
internal sealed class PostgresRevocationStore : IAuthorityRevocationStore
{
private readonly RevocationRepository repository;
private readonly IRevocationRepository repository;
private readonly IAuthorityIdGenerator idGenerator;
public PostgresRevocationStore(RevocationRepository repository)
public PostgresRevocationStore(
IRevocationRepository repository,
IAuthorityIdGenerator idGenerator)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var entity = new RevocationEntity
{
Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id,
Id = string.IsNullOrWhiteSpace(document.Id) ? idGenerator.NextId() : document.Id,
Category = document.Category,
RevocationId = document.RevocationId,
SubjectId = document.SubjectId,

View File

@@ -3,6 +3,7 @@ using StellaOps.Authority.Persistence.Sessions;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Storage;
namespace StellaOps.Authority.Persistence.PostgresAdapters;
@@ -11,11 +12,18 @@ namespace StellaOps.Authority.Persistence.PostgresAdapters;
/// </summary>
internal sealed class PostgresServiceAccountStore : IAuthorityServiceAccountStore
{
private readonly ServiceAccountRepository repository;
private readonly IServiceAccountRepository repository;
private readonly TimeProvider timeProvider;
private readonly IAuthorityIdGenerator idGenerator;
public PostgresServiceAccountStore(ServiceAccountRepository repository)
public PostgresServiceAccountStore(
IServiceAccountRepository repository,
TimeProvider timeProvider,
IAuthorityIdGenerator idGenerator)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public async ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
@@ -38,9 +46,10 @@ internal sealed class PostgresServiceAccountStore : IAuthorityServiceAccountStor
public async ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var now = timeProvider.GetUtcNow();
var entity = new ServiceAccountEntity
{
Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id,
Id = string.IsNullOrWhiteSpace(document.Id) ? idGenerator.NextId() : document.Id,
AccountId = document.AccountId,
Tenant = document.Tenant,
DisplayName = document.DisplayName,
@@ -49,8 +58,8 @@ internal sealed class PostgresServiceAccountStore : IAuthorityServiceAccountStor
AllowedScopes = document.AllowedScopes,
AuthorizedClients = document.AuthorizedClients,
Attributes = document.Attributes,
CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt,
UpdatedAt = document.UpdatedAt == default ? DateTimeOffset.UtcNow : document.UpdatedAt
CreatedAt = document.CreatedAt == default ? now : document.CreatedAt,
UpdatedAt = document.UpdatedAt == default ? now : document.UpdatedAt
};
await repository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false);
@@ -71,8 +80,8 @@ internal sealed class PostgresServiceAccountStore : IAuthorityServiceAccountStor
Enabled = entity.Enabled,
AllowedScopes = entity.AllowedScopes.ToList(),
AuthorizedClients = entity.AuthorizedClients.ToList(),
Attributes = entity.Attributes.ToDictionary(kv => kv.Key, kv => kv.Value.ToList(), StringComparer.OrdinalIgnoreCase),
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt
Attributes = entity.Attributes.ToDictionary(kv => kv.Key, kv => kv.Value.ToList(), StringComparer.OrdinalIgnoreCase),
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt
};
}

View File

@@ -5,6 +5,7 @@ using StellaOps.Authority.Persistence.Sessions;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Storage;
namespace StellaOps.Authority.Persistence.PostgresAdapters;
@@ -13,12 +14,24 @@ namespace StellaOps.Authority.Persistence.PostgresAdapters;
/// </summary>
internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefreshTokenStore
{
private readonly OidcTokenRepository repository;
private readonly ConcurrentDictionary<string, HashSet<string>> deviceFingerprints = new(StringComparer.OrdinalIgnoreCase);
private static readonly TimeSpan ReplayWindow = TimeSpan.FromHours(6);
private const int ReplaySweepInterval = 128;
private const int RevokeByClientPageSize = 200;
public PostgresTokenStore(OidcTokenRepository repository)
private readonly IOidcTokenRepository repository;
private readonly TimeProvider timeProvider;
private readonly IAuthorityIdGenerator idGenerator;
private readonly ConcurrentDictionary<string, TokenUsageTracker> deviceFingerprints = new(StringComparer.OrdinalIgnoreCase);
private int replaySweepCounter;
public PostgresTokenStore(
IOidcTokenRepository repository,
TimeProvider timeProvider,
IAuthorityIdGenerator idGenerator)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public async ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
@@ -41,73 +54,41 @@ internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefre
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListByScopeAsync(string scope, string tenant, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var items = await repository.ListAsync(Math.Max(limit * 2, limit), cancellationToken).ConfigureAwait(false);
var documents = items
.Select(Map)
.Where(t => string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
.Where(t => issuedAfter is null || t.CreatedAt >= issuedAfter.Value)
.Where(t => t.Scope.Any(s => string.Equals(s, scope, StringComparison.Ordinal)))
.OrderByDescending(t => t.CreatedAt)
.Take(limit)
.ToArray();
return documents;
var items = await repository.ListByScopeAsync(tenant, scope, issuedAfter, limit, cancellationToken).ConfigureAwait(false);
return items.Select(Map).ToArray();
}
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(string? tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var items = await repository.ListAsync(500, cancellationToken).ConfigureAwait(false);
var documents = items
.Select(Map)
.Where(t => string.Equals(t.Status, "revoked", StringComparison.OrdinalIgnoreCase))
.Where(t => string.IsNullOrWhiteSpace(tenant) || string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
.OrderBy(t => t.TokenId, StringComparer.Ordinal)
.ToArray();
return documents;
var items = await repository.ListRevokedAsync(tenant, int.MaxValue, cancellationToken).ConfigureAwait(false);
return items.Select(Map).ToArray();
}
public async ValueTask<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var items = await repository.ListAsync(500, cancellationToken).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var count = items
.Select(Map)
.Where(t => string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
.Where(t => string.IsNullOrWhiteSpace(serviceAccountId) || string.Equals(t.ServiceAccountId, serviceAccountId, StringComparison.Ordinal))
.Where(t => !string.Equals(t.Status, "revoked", StringComparison.OrdinalIgnoreCase))
.Where(t => t.ExpiresAt is null || t.ExpiresAt > now)
.LongCount();
return count;
var now = timeProvider.GetUtcNow();
return await repository.CountActiveDelegationTokensAsync(tenant, serviceAccountId, now, cancellationToken).ConfigureAwait(false);
}
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var items = await repository.ListAsync(500, cancellationToken).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var documents = items
.Select(Map)
.Where(t => string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
.Where(t => string.IsNullOrWhiteSpace(serviceAccountId) || string.Equals(t.ServiceAccountId, serviceAccountId, StringComparison.Ordinal))
.Where(t => !string.Equals(t.Status, "revoked", StringComparison.OrdinalIgnoreCase))
.Where(t => t.ExpiresAt is null || t.ExpiresAt > now)
.ToArray();
return documents;
var now = timeProvider.GetUtcNow();
var items = await repository.ListActiveDelegationTokensAsync(tenant, serviceAccountId, now, int.MaxValue, cancellationToken).ConfigureAwait(false);
return items.Select(Map).ToArray();
}
public async ValueTask UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var now = timeProvider.GetUtcNow();
var entity = new OidcTokenEntity
{
Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id,
Id = string.IsNullOrWhiteSpace(document.Id) ? idGenerator.NextId() : document.Id,
TokenId = document.TokenId,
SubjectId = document.SubjectId,
ClientId = document.ClientId,
TokenType = string.IsNullOrWhiteSpace(document.TokenType) ? document.Type : document.TokenType,
ReferenceId = document.ReferenceId,
CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt,
CreatedAt = document.CreatedAt == default ? now : document.CreatedAt,
ExpiresAt = document.ExpiresAt,
RedeemedAt = document.RedeemedAt,
Payload = document.Payload,
@@ -126,7 +107,7 @@ internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefre
}
existing.Status = "revoked";
existing.RevokedAt = DateTimeOffset.UtcNow;
existing.RevokedAt = timeProvider.GetUtcNow();
await UpsertAsync(existing, cancellationToken, session).ConfigureAwait(false);
return true;
}
@@ -148,16 +129,32 @@ internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefre
public async ValueTask<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var items = await repository.ListAsync(500, cancellationToken).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
var affected = 0;
var offset = 0;
foreach (var doc in items.Select(Map).Where(t => string.Equals(t.ClientId, clientId, StringComparison.Ordinal)))
while (true)
{
doc.Status = "revoked";
doc.RevokedAt = now;
await UpsertAsync(doc, cancellationToken, session).ConfigureAwait(false);
affected++;
var items = await repository.ListByClientAsync(clientId, RevokeByClientPageSize, offset, cancellationToken).ConfigureAwait(false);
if (items.Count == 0)
{
break;
}
foreach (var doc in items.Select(Map))
{
doc.Status = "revoked";
doc.RevokedAt = now;
await UpsertAsync(doc, cancellationToken, session).ConfigureAwait(false);
affected++;
}
if (items.Count < RevokeByClientPageSize)
{
break;
}
offset += items.Count;
}
return affected;
@@ -180,9 +177,19 @@ internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefre
var key = tokenId.Trim();
var fingerprint = $"{remoteAddress}|{userAgent}";
var set = deviceFingerprints.GetOrAdd(key, static _ => new HashSet<string>(StringComparer.Ordinal));
var isNew = set.Add(fingerprint);
var status = isNew && set.Count > 1 ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded;
var tracker = deviceFingerprints.GetOrAdd(key, static _ => new TokenUsageTracker());
var status = tracker.Record(fingerprint, observedAt, ReplayWindow);
if (tracker.IsEmpty)
{
deviceFingerprints.TryRemove(key, out _);
}
if (Interlocked.Increment(ref replaySweepCounter) % ReplaySweepInterval == 0)
{
SweepExpired(observedAt);
}
return ValueTask.FromResult(new TokenUsageUpdateResult(status, remoteAddress, userAgent));
}
@@ -200,14 +207,15 @@ internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefre
public async ValueTask UpsertAsync(AuthorityRefreshTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var now = timeProvider.GetUtcNow();
var entity = new OidcRefreshTokenEntity
{
Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id,
Id = string.IsNullOrWhiteSpace(document.Id) ? idGenerator.NextId() : document.Id,
TokenId = document.TokenId,
SubjectId = document.SubjectId,
ClientId = document.ClientId,
Handle = document.Handle,
CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt,
CreatedAt = document.CreatedAt == default ? now : document.CreatedAt,
ExpiresAt = document.ExpiresAt,
ConsumedAt = document.ConsumedAt,
Payload = document.Payload
@@ -224,7 +232,7 @@ internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefre
public async ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var items = await repository.ListBySubjectAsync(subjectId, 200, cancellationToken).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
var affected = 0;
foreach (var doc in items.Select(Map))
@@ -239,6 +247,70 @@ internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefre
return affected;
}
private void SweepExpired(DateTimeOffset observedAt)
{
var cutoff = observedAt - ReplayWindow;
foreach (var pair in deviceFingerprints)
{
if (pair.Value.IsExpired(cutoff))
{
deviceFingerprints.TryRemove(pair.Key, out _);
}
}
}
private sealed class TokenUsageTracker
{
private readonly object gate = new();
private readonly Dictionary<string, DateTimeOffset> fingerprints = new(StringComparer.Ordinal);
private DateTimeOffset lastObservedAt;
public bool IsEmpty
{
get
{
lock (gate)
{
return fingerprints.Count == 0;
}
}
}
public TokenUsageUpdateStatus Record(string fingerprint, DateTimeOffset observedAt, TimeSpan replayWindow)
{
lock (gate)
{
var cutoff = observedAt - replayWindow;
if (fingerprints.Count > 0)
{
foreach (var entry in fingerprints.Where(entry => entry.Value < cutoff).ToArray())
{
fingerprints.Remove(entry.Key);
}
}
var isNew = fingerprints.TryAdd(fingerprint, observedAt);
if (!isNew)
{
fingerprints[fingerprint] = observedAt;
}
lastObservedAt = observedAt;
return isNew && fingerprints.Count > 1
? TokenUsageUpdateStatus.SuspectedReplay
: TokenUsageUpdateStatus.Recorded;
}
}
public bool IsExpired(DateTimeOffset cutoff)
{
lock (gate)
{
return lastObservedAt < cutoff;
}
}
}
private static AuthorityTokenDocument Map(OidcTokenEntity entity)
{
var properties = entity.Properties.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0085-M | DONE | Maintainability audit for StellaOps.Authority. |
| AUDIT-0085-T | DONE | Test coverage audit for StellaOps.Authority. |
| AUDIT-0085-A | TODO | Pending approval for changes. |
| AUDIT-0085-A | DONE | Store determinism, replay tracking, issuer IDs, and tests. |

View File

@@ -10,6 +10,7 @@ using StellaOps.Auth.Abstractions;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using StellaOps.Authority.Vulnerability;
using StellaOps.Authority.Storage;
namespace StellaOps.Authority.Vulnerability.Attachments;
@@ -18,17 +19,20 @@ internal sealed class VulnAttachmentTokenIssuer
private readonly ICryptoProviderRegistry cryptoRegistry;
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor;
private readonly TimeProvider timeProvider;
private readonly IAuthorityIdGenerator idGenerator;
private readonly ILogger<VulnAttachmentTokenIssuer> logger;
public VulnAttachmentTokenIssuer(
ICryptoProviderRegistry cryptoRegistry,
IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor,
TimeProvider timeProvider,
IAuthorityIdGenerator idGenerator,
ILogger<VulnAttachmentTokenIssuer> logger)
{
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
this.authorityOptionsAccessor = authorityOptionsAccessor ?? throw new ArgumentNullException(nameof(authorityOptionsAccessor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -79,7 +83,7 @@ internal sealed class VulnAttachmentTokenIssuer
var issuedAt = timeProvider.GetUtcNow();
var expiresAt = issuedAt.Add(lifetime);
var tokenId = Guid.NewGuid().ToString("N");
var tokenId = idGenerator.NextId();
var payload = new VulnAttachmentTokenPayload(
Issuer: issuer.ToString(),

View File

@@ -13,6 +13,7 @@ using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Vulnerability;
using StellaOps.Authority.Storage;
using StellaOps.Configuration;
using StellaOps.Cryptography;
@@ -37,17 +38,20 @@ internal sealed class VulnWorkflowAntiForgeryTokenIssuer
private readonly ICryptoProviderRegistry cryptoRegistry;
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor;
private readonly TimeProvider timeProvider;
private readonly IAuthorityIdGenerator idGenerator;
private readonly ILogger<VulnWorkflowAntiForgeryTokenIssuer> logger;
public VulnWorkflowAntiForgeryTokenIssuer(
ICryptoProviderRegistry cryptoRegistry,
IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor,
TimeProvider timeProvider,
IAuthorityIdGenerator idGenerator,
ILogger<VulnWorkflowAntiForgeryTokenIssuer> logger)
{
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
this.authorityOptionsAccessor = authorityOptionsAccessor ?? throw new ArgumentNullException(nameof(authorityOptionsAccessor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -102,7 +106,7 @@ internal sealed class VulnWorkflowAntiForgeryTokenIssuer
var issuedAt = timeProvider.GetUtcNow();
var expiresAt = issuedAt.Add(lifetime);
var tokenId = Guid.NewGuid().ToString("N");
var tokenId = idGenerator.NextId();
var payload = new VulnWorkflowAntiForgeryPayload(
Issuer: issuer.ToString(),
@@ -212,11 +216,11 @@ internal sealed class VulnWorkflowAntiForgeryTokenIssuer
return set.OrderBy(static value => value, StringComparer.Ordinal).ToList();
}
private static string NormalizeOrGenerateNonce(string? nonce)
private string NormalizeOrGenerateNonce(string? nonce)
{
if (string.IsNullOrWhiteSpace(nonce))
{
return Guid.NewGuid().ToString("N");
return idGenerator.NextId();
}
var normalized = nonce.Trim();

View File

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

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0086-M | DONE | Maintainability audit for StellaOps.Authority.Core. |
| AUDIT-0086-T | DONE | Test coverage audit for StellaOps.Authority.Core. |
| AUDIT-0086-A | TODO | Pending approval for changes. |
| AUDIT-0086-A | DONE | Deterministic builder defaults, replay verifier handling, and tests. |

View File

@@ -75,7 +75,7 @@ public sealed class NullVerdictManifestSigner : IVerdictManifestSigner
public Task<SignatureVerificationResult> VerifyAsync(VerdictManifest manifest, CancellationToken ct = default)
=> Task.FromResult(new SignatureVerificationResult
{
Valid = true,
Valid = false,
Error = "Signing disabled",
});
}

View File

@@ -161,7 +161,7 @@ public static class VerdictManifestSerializer
};
/// <summary>
/// Serialize manifest to canonical JSON (sorted keys, no indentation).
/// Serialize manifest to deterministic JSON (stable naming policy, no indentation).
/// </summary>
public static string Serialize(VerdictManifest manifest)
{

View File

@@ -14,17 +14,25 @@ public sealed class VerdictManifestBuilder
private VerdictResult? _result;
private string? _policyHash;
private string? _latticeVersion;
private DateTimeOffset _evaluatedAt = DateTimeOffset.UtcNow;
private DateTimeOffset _evaluatedAt;
private readonly Func<string> _idGenerator;
private readonly TimeProvider _timeProvider;
public VerdictManifestBuilder()
: this(() => Guid.NewGuid().ToString("n"))
: this(() => Guid.NewGuid().ToString("n"), TimeProvider.System)
{
}
public VerdictManifestBuilder(Func<string> idGenerator)
: this(idGenerator, TimeProvider.System)
{
}
public VerdictManifestBuilder(Func<string> idGenerator, TimeProvider timeProvider)
{
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_evaluatedAt = _timeProvider.GetUtcNow();
}
public VerdictManifestBuilder WithTenant(string tenant)
@@ -74,7 +82,7 @@ public sealed class VerdictManifestBuilder
VulnFeedSnapshotIds = SortedImmutable(vulnFeedSnapshotIds),
VexDocumentDigests = SortedImmutable(vexDocumentDigests),
ReachabilityGraphIds = SortedImmutable(reachabilityGraphIds ?? Enumerable.Empty<string>()),
ClockCutoff = clockCutoff ?? DateTimeOffset.UtcNow,
ClockCutoff = clockCutoff ?? _timeProvider.GetUtcNow(),
};
return this;
}

View File

@@ -100,14 +100,8 @@ public sealed class VerdictReplayVerifier : IVerdictReplayVerifier
{
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
// We need to find the manifest - this requires a search across tenants
// In practice, the caller should provide the tenant or the manifest directly
return new ReplayVerificationResult
{
Success = false,
OriginalManifest = null!,
Error = "Use VerifyAsync(VerdictManifest) overload with the full manifest.",
};
throw new InvalidOperationException(
"Verdict replay requires a full manifest or tenant context; use VerifyAsync(VerdictManifest) instead.");
}
public async Task<ReplayVerificationResult> VerifyAsync(VerdictManifest manifest, CancellationToken ct = default)

View File

@@ -79,5 +79,13 @@ public static class AuthorityPersistenceExtensions
services.AddScoped<IOfflineKitAuditRepository>(sp => sp.GetRequiredService<OfflineKitAuditRepository>());
services.AddScoped<IOfflineKitAuditEmitter, OfflineKitAuditEmitter>();
services.AddScoped<RevocationExportStateRepository>();
services.AddScoped<IBootstrapInviteRepository>(sp => sp.GetRequiredService<BootstrapInviteRepository>());
services.AddScoped<IServiceAccountRepository>(sp => sp.GetRequiredService<ServiceAccountRepository>());
services.AddScoped<IClientRepository>(sp => sp.GetRequiredService<ClientRepository>());
services.AddScoped<IRevocationRepository>(sp => sp.GetRequiredService<RevocationRepository>());
services.AddScoped<ILoginAttemptRepository>(sp => sp.GetRequiredService<LoginAttemptRepository>());
services.AddScoped<IOidcTokenRepository>(sp => sp.GetRequiredService<OidcTokenRepository>());
services.AddScoped<IAirgapAuditRepository>(sp => sp.GetRequiredService<AirgapAuditRepository>());
}
}

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for airgap audit records.
/// </summary>
public sealed class AirgapAuditRepository : RepositoryBase<AuthorityDataSource>
public sealed class AirgapAuditRepository : RepositoryBase<AuthorityDataSource>, IAirgapAuditRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for bootstrap invites.
/// </summary>
public sealed class BootstrapInviteRepository : RepositoryBase<AuthorityDataSource>
public sealed class BootstrapInviteRepository : RepositoryBase<AuthorityDataSource>, IBootstrapInviteRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for OAuth/OpenID clients.
/// </summary>
public sealed class ClientRepository : RepositoryBase<AuthorityDataSource>
public sealed class ClientRepository : RepositoryBase<AuthorityDataSource>, IClientRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);

View File

@@ -0,0 +1,9 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface IAirgapAuditRepository
{
Task InsertAsync(AirgapAuditEntity entity, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AirgapAuditEntity>> ListAsync(int limit, int offset, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface IBootstrapInviteRepository
{
Task<BootstrapInviteEntity?> FindByTokenAsync(string token, CancellationToken cancellationToken = default);
Task InsertAsync(BootstrapInviteEntity entity, CancellationToken cancellationToken = default);
Task<bool> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken = default);
Task<bool> ReleaseAsync(string token, CancellationToken cancellationToken = default);
Task<bool> ConsumeAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken = default);
Task<IReadOnlyList<BootstrapInviteEntity>> ExpireAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface IClientRepository
{
Task<ClientEntity?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken = default);
Task UpsertAsync(ClientEntity entity, CancellationToken cancellationToken = default);
Task<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,9 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface ILoginAttemptRepository
{
Task InsertAsync(LoginAttemptEntity entity, CancellationToken cancellationToken = default);
Task<IReadOnlyList<LoginAttemptEntity>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,25 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface IOidcTokenRepository
{
Task<OidcTokenEntity?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken = default);
Task<OidcTokenEntity?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OidcTokenEntity>> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OidcTokenEntity>> ListByClientAsync(string clientId, int limit, int offset, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OidcTokenEntity>> ListByScopeAsync(string tenant, string scope, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OidcTokenEntity>> ListRevokedAsync(string? tenant, int limit, CancellationToken cancellationToken = default);
Task<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OidcTokenEntity>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, int limit, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OidcTokenEntity>> ListAsync(int limit, CancellationToken cancellationToken = default);
Task UpsertAsync(OidcTokenEntity entity, CancellationToken cancellationToken = default);
Task<bool> RevokeAsync(string tokenId, CancellationToken cancellationToken = default);
Task<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken = default);
Task<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken = default);
Task<OidcRefreshTokenEntity?> FindRefreshTokenAsync(string tokenId, CancellationToken cancellationToken = default);
Task<OidcRefreshTokenEntity?> FindRefreshTokenByHandleAsync(string handle, CancellationToken cancellationToken = default);
Task UpsertRefreshTokenAsync(OidcRefreshTokenEntity entity, CancellationToken cancellationToken = default);
Task<bool> ConsumeRefreshTokenAsync(string tokenId, CancellationToken cancellationToken = default);
Task<int> RevokeRefreshTokensBySubjectAsync(string subjectId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface IRevocationRepository
{
Task UpsertAsync(RevocationEntity entity, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RevocationEntity>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default);
Task RemoveAsync(string category, string revocationId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,11 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface IServiceAccountRepository
{
Task<ServiceAccountEntity?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ServiceAccountEntity>> ListAsync(string? tenant, CancellationToken cancellationToken = default);
Task UpsertAsync(ServiceAccountEntity entity, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string accountId, CancellationToken cancellationToken = default);
}

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for login attempts.
/// </summary>
public sealed class LoginAttemptRepository : RepositoryBase<AuthorityDataSource>
public sealed class LoginAttemptRepository : RepositoryBase<AuthorityDataSource>, ILoginAttemptRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for OpenIddict tokens and refresh tokens.
/// </summary>
public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>
public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>, IOidcTokenRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
@@ -69,6 +69,130 @@ public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListByClientAsync(string clientId, int limit, int offset, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
FROM authority.oidc_tokens
WHERE client_id = @client_id
ORDER BY created_at DESC, id DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "client_id", clientId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
mapRow: MapToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListByScopeAsync(string tenant, string scope, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
FROM authority.oidc_tokens
WHERE (properties->>'tenant') = @tenant
AND position(' ' || @scope || ' ' IN ' ' || COALESCE(properties->>'scope', '') || ' ') > 0
AND (@issued_after IS NULL OR created_at >= @issued_after)
ORDER BY created_at DESC, id DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "tenant", tenant);
AddParameter(cmd, "scope", scope);
AddParameter(cmd, "issued_after", issuedAfter);
AddParameter(cmd, "limit", limit);
},
mapRow: MapToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListRevokedAsync(string? tenant, int limit, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
FROM authority.oidc_tokens
WHERE lower(COALESCE(properties->>'status', 'valid')) = 'revoked'
AND (@tenant IS NULL OR (properties->>'tenant') = @tenant)
ORDER BY token_id ASC, id ASC
LIMIT @limit
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "tenant", tenant);
AddParameter(cmd, "limit", limit);
},
mapRow: MapToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT COUNT(*)
FROM authority.oidc_tokens
WHERE (properties->>'tenant') = @tenant
AND (@service_account_id IS NULL OR (properties->>'service_account_id') = @service_account_id)
AND lower(COALESCE(properties->>'status', 'valid')) <> 'revoked'
AND (expires_at IS NULL OR expires_at > @now)
""";
var count = await ExecuteScalarAsync<long>(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "tenant", tenant);
AddParameter(cmd, "service_account_id", serviceAccountId);
AddParameter(cmd, "now", now);
},
cancellationToken: cancellationToken).ConfigureAwait(false);
return count ?? 0;
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, int limit, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
FROM authority.oidc_tokens
WHERE (properties->>'tenant') = @tenant
AND (@service_account_id IS NULL OR (properties->>'service_account_id') = @service_account_id)
AND lower(COALESCE(properties->>'status', 'valid')) <> 'revoked'
AND (expires_at IS NULL OR expires_at > @now)
ORDER BY created_at DESC, id DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "tenant", tenant);
AddParameter(cmd, "service_account_id", serviceAccountId);
AddParameter(cmd, "now", now);
AddParameter(cmd, "limit", limit);
},
mapRow: MapToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListAsync(int limit, CancellationToken cancellationToken = default)
{
const string sql = """

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for revocations.
/// </summary>
public sealed class RevocationRepository : RepositoryBase<AuthorityDataSource>
public sealed class RevocationRepository : RepositoryBase<AuthorityDataSource>, IRevocationRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for service accounts.
/// </summary>
public sealed class ServiceAccountRepository : RepositoryBase<AuthorityDataSource>
public sealed class ServiceAccountRepository : RepositoryBase<AuthorityDataSource>, IServiceAccountRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);

View File

@@ -79,5 +79,13 @@ public static class ServiceCollectionExtensions
services.AddScoped<IOfflineKitAuditRepository>(sp => sp.GetRequiredService<OfflineKitAuditRepository>());
services.AddScoped<IOfflineKitAuditEmitter, OfflineKitAuditEmitter>();
services.AddScoped<RevocationExportStateRepository>();
services.AddScoped<IBootstrapInviteRepository>(sp => sp.GetRequiredService<BootstrapInviteRepository>());
services.AddScoped<IServiceAccountRepository>(sp => sp.GetRequiredService<ServiceAccountRepository>());
services.AddScoped<IClientRepository>(sp => sp.GetRequiredService<ClientRepository>());
services.AddScoped<IRevocationRepository>(sp => sp.GetRequiredService<RevocationRepository>());
services.AddScoped<ILoginAttemptRepository>(sp => sp.GetRequiredService<LoginAttemptRepository>());
services.AddScoped<IOidcTokenRepository>(sp => sp.GetRequiredService<OidcTokenRepository>());
services.AddScoped<IAirgapAuditRepository>(sp => sp.GetRequiredService<AirgapAuditRepository>());
}
}

View File

@@ -8,6 +8,7 @@ namespace StellaOps.Authority.Core.Tests.Verdicts;
public sealed class InMemoryVerdictManifestStoreTests
{
private readonly InMemoryVerdictManifestStore _store = new();
private static readonly DateTimeOffset BaseTime = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
[Fact]
public async Task StoreAndRetrieve_ByManifestId()
@@ -59,7 +60,7 @@ public sealed class InMemoryVerdictManifestStoreTests
for (var i = 0; i < 5; i++)
{
var manifest = CreateManifest($"m{i}", "t", policyHash: "p1", latticeVersion: "v1",
evaluatedAt: DateTimeOffset.UtcNow.AddMinutes(-i));
evaluatedAt: BaseTime.AddMinutes(-i));
await _store.StoreAsync(manifest);
}
@@ -137,7 +138,7 @@ public sealed class InMemoryVerdictManifestStoreTests
VulnFeedSnapshotIds = ImmutableArray.Create("feed-1"),
VexDocumentDigests = ImmutableArray.Create("sha256:vex"),
ReachabilityGraphIds = ImmutableArray<string>.Empty,
ClockCutoff = DateTimeOffset.UtcNow,
ClockCutoff = BaseTime,
},
Result = new VerdictResult
{
@@ -148,7 +149,7 @@ public sealed class InMemoryVerdictManifestStoreTests
},
PolicyHash = policyHash,
LatticeVersion = latticeVersion,
EvaluatedAt = evaluatedAt ?? DateTimeOffset.UtcNow,
EvaluatedAt = evaluatedAt ?? BaseTime,
ManifestDigest = $"sha256:{manifestId}",
};
}

View File

@@ -0,0 +1,48 @@
using System;
using System.Collections.Immutable;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Authority.Core.Verdicts;
using Xunit;
namespace StellaOps.Authority.Core.Tests.Verdicts;
public sealed class NullVerdictManifestSignerTests
{
[Fact]
public async Task VerifyAsync_ReturnsInvalidWithDisabledReason()
{
var signer = new NullVerdictManifestSigner();
var manifest = new VerdictManifest
{
ManifestId = "manifest-1",
Tenant = "tenant-a",
AssetDigest = "sha256:asset",
VulnerabilityId = "CVE-2024-1234",
Inputs = new VerdictInputs
{
SbomDigests = ImmutableArray.Create("sha256:sbom"),
VulnFeedSnapshotIds = ImmutableArray.Create("feed-1"),
VexDocumentDigests = ImmutableArray.Create("sha256:vex"),
ReachabilityGraphIds = ImmutableArray<string>.Empty,
ClockCutoff = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
},
Result = new VerdictResult
{
Status = VexStatus.NotAffected,
Confidence = 0.5,
Explanations = ImmutableArray<VerdictExplanation>.Empty,
EvidenceRefs = ImmutableArray<string>.Empty,
},
PolicyHash = "sha256:policy",
LatticeVersion = "1.0.0",
EvaluatedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
ManifestDigest = "sha256:manifest",
};
var result = await signer.VerifyAsync(manifest);
result.Valid.Should().BeFalse();
result.Error.Should().Be("Signing disabled");
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Authority.Core.Verdicts;
using Xunit;
@@ -10,7 +11,8 @@ public sealed class VerdictManifestBuilderTests
[Fact]
public void Build_CreatesValidManifest()
{
var builder = new VerdictManifestBuilder(() => "test-manifest-id")
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T12:00:00Z"));
var builder = new VerdictManifestBuilder(() => "test-manifest-id", clock)
.WithTenant("tenant-1")
.WithAsset("sha256:abc123", "CVE-2024-1234")
.WithInputs(
@@ -59,7 +61,7 @@ public sealed class VerdictManifestBuilderTests
VerdictManifest BuildManifest(int seed)
{
return new VerdictManifestBuilder(() => "fixed-id")
return new VerdictManifestBuilder(() => "fixed-id", TimeProvider.System)
.WithTenant("tenant")
.WithAsset("sha256:asset", "CVE-2024-0001")
.WithInputs(
@@ -104,7 +106,7 @@ public sealed class VerdictManifestBuilderTests
{
var clock = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var manifestA = new VerdictManifestBuilder(() => "id")
var manifestA = new VerdictManifestBuilder(() => "id", TimeProvider.System)
.WithTenant("t")
.WithAsset("sha256:a", "CVE-1")
.WithInputs(
@@ -117,7 +119,7 @@ public sealed class VerdictManifestBuilderTests
.WithClock(clock)
.Build();
var manifestB = new VerdictManifestBuilder(() => "id")
var manifestB = new VerdictManifestBuilder(() => "id", TimeProvider.System)
.WithTenant("t")
.WithAsset("sha256:a", "CVE-1")
.WithInputs(
@@ -148,14 +150,15 @@ public sealed class VerdictManifestBuilderTests
[Fact]
public void Build_NormalizesVulnerabilityIdToUpperCase()
{
var manifest = new VerdictManifestBuilder(() => "id")
var clock = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var manifest = new VerdictManifestBuilder(() => "id", TimeProvider.System)
.WithTenant("t")
.WithAsset("sha256:a", "cve-2024-1234")
.WithInputs(
sbomDigests: new[] { "sha256:s" },
vulnFeedSnapshotIds: new[] { "f" },
vexDocumentDigests: new[] { "v" },
clockCutoff: DateTimeOffset.UtcNow)
clockCutoff: clock)
.WithResult(VexStatus.Affected, 0.5, Enumerable.Empty<VerdictExplanation>())
.WithPolicy("p", "v")
.Build();

View File

@@ -0,0 +1,125 @@
using System;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Authority.Core.Verdicts;
using Xunit;
namespace StellaOps.Authority.Core.Tests.Verdicts;
public sealed class VerdictReplayVerifierTests
{
[Fact]
public async Task VerifyAsync_ByManifestId_Throws()
{
var verifier = new VerdictReplayVerifier(new NullStore(), new NullVerdictManifestSigner(), new NullEvaluator());
var act = async () => await verifier.VerifyAsync("manifest-1", CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task VerifyAsync_FailsWhenSignatureInvalid()
{
var verifier = new VerdictReplayVerifier(new NullStore(), new NullVerdictManifestSigner(), new NullEvaluator());
var manifest = CreateManifest();
var result = await verifier.VerifyAsync(manifest, CancellationToken.None);
result.Success.Should().BeFalse();
result.SignatureValid.Should().BeFalse();
result.Error.Should().Contain("Signature verification failed");
}
private static VerdictManifest CreateManifest()
{
return new VerdictManifest
{
ManifestId = "manifest-1",
Tenant = "tenant-a",
AssetDigest = "sha256:asset",
VulnerabilityId = "CVE-2024-1234",
Inputs = new VerdictInputs
{
SbomDigests = ImmutableArray.Create("sha256:sbom"),
VulnFeedSnapshotIds = ImmutableArray.Create("feed-1"),
VexDocumentDigests = ImmutableArray.Create("sha256:vex"),
ReachabilityGraphIds = ImmutableArray<string>.Empty,
ClockCutoff = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
},
Result = new VerdictResult
{
Status = VexStatus.NotAffected,
Confidence = 0.5,
Explanations = ImmutableArray<VerdictExplanation>.Empty,
EvidenceRefs = ImmutableArray<string>.Empty,
},
PolicyHash = "sha256:policy",
LatticeVersion = "1.0.0",
EvaluatedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
ManifestDigest = "sha256:manifest",
SignatureBase64 = "invalid"
};
}
private sealed class NullStore : IVerdictManifestStore
{
public Task<VerdictManifest> StoreAsync(VerdictManifest manifest, CancellationToken ct = default)
=> Task.FromResult(manifest);
public Task<VerdictManifest?> GetByIdAsync(string tenant, string manifestId, CancellationToken ct = default)
=> Task.FromResult<VerdictManifest?>(null);
public Task<VerdictManifest?> GetByScopeAsync(
string tenant,
string assetDigest,
string vulnerabilityId,
string? policyHash = null,
string? latticeVersion = null,
CancellationToken ct = default)
=> Task.FromResult<VerdictManifest?>(null);
public Task<VerdictManifestPage> ListByPolicyAsync(
string tenant,
string policyHash,
string latticeVersion,
int limit = 100,
string? pageToken = null,
CancellationToken ct = default)
=> Task.FromResult(new VerdictManifestPage { Manifests = ImmutableArray<VerdictManifest>.Empty });
public Task<VerdictManifestPage> ListByAssetAsync(
string tenant,
string assetDigest,
int limit = 100,
string? pageToken = null,
CancellationToken ct = default)
=> Task.FromResult(new VerdictManifestPage { Manifests = ImmutableArray<VerdictManifest>.Empty });
public Task<bool> DeleteAsync(string tenant, string manifestId, CancellationToken ct = default)
=> Task.FromResult(false);
}
private sealed class NullEvaluator : IVerdictEvaluator
{
public Task<VerdictResult> EvaluateAsync(
string tenant,
string assetDigest,
string vulnerabilityId,
VerdictInputs inputs,
string policyHash,
string latticeVersion,
CancellationToken ct = default)
{
return Task.FromResult(new VerdictResult
{
Status = VexStatus.NotAffected,
Confidence = 0.5,
Explanations = ImmutableArray<VerdictExplanation>.Empty,
EvidenceRefs = ImmutableArray<string>.Empty,
});
}
}
}