save progress
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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\"}";
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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>());
|
||||
|
||||
@@ -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>());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user