This commit is contained in:
master
2026-02-04 19:59:20 +02:00
parent 557feefdc3
commit 5548cf83bf
1479 changed files with 53557 additions and 40339 deletions

View File

@@ -0,0 +1,33 @@
using FluentAssertions;
using StellaOps.IssuerDirectory.Core.Domain;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests.Domain;
public class IssuerKeyRecordTests
{
[Fact]
public void WithStatus_Retired_SetsTimestamps()
{
var record = IssuerKeyRecord.Create(
id: "key-1",
issuerId: "issuer-1",
tenantId: "tenant-a",
type: IssuerKeyType.Ed25519PublicKey,
material: new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32])),
fingerprint: "fp-1",
createdAtUtc: DateTimeOffset.Parse("2025-11-01T00:00:00Z"),
createdBy: "seed",
expiresAtUtc: null,
replacesKeyId: null);
var retiredAt = DateTimeOffset.Parse("2025-11-02T00:00:00Z");
var retired = record.WithStatus(IssuerKeyStatus.Retired, retiredAt, "editor");
retired.Status.Should().Be(IssuerKeyStatus.Retired);
retired.RetiredAtUtc.Should().Be(retiredAt);
retired.RevokedAtUtc.Should().BeNull();
}
}

View File

@@ -0,0 +1,62 @@
using FluentAssertions;
using StellaOps.IssuerDirectory.Core.Domain;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests.Domain;
public class IssuerRecordTests
{
[Fact]
public void Create_NormalizesSlugAndTags()
{
var record = IssuerRecord.Create(
id: "red-hat",
tenantId: "tenant-a",
displayName: "Red Hat",
slug: " Red-Hat ",
description: null,
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com"), null),
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/cve"), null, new[] { "en" }, null),
endpoints: Array.Empty<IssuerEndpoint>(),
tags: new[] { "Vendor", " vendor ", "Partner", " " },
timestampUtc: DateTimeOffset.Parse("2025-11-01T00:00:00Z"),
actor: "seed",
isSystemSeed: false);
record.Slug.Should().Be("red-hat");
record.Tags.Should().Equal("vendor", "partner");
}
[Fact]
public void WithUpdated_NormalizesTagsAndDescription()
{
var record = IssuerRecord.Create(
id: "red-hat",
tenantId: "tenant-a",
displayName: "Red Hat",
slug: "red-hat",
description: "Initial",
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com"), null),
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/cve"), null, new[] { "en" }, null),
endpoints: Array.Empty<IssuerEndpoint>(),
tags: new[] { "vendor" },
timestampUtc: DateTimeOffset.Parse("2025-11-01T00:00:00Z"),
actor: "seed",
isSystemSeed: false);
var updated = record.WithUpdated(
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com"), null),
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/cve"), null, new[] { "en" }, null),
endpoints: Array.Empty<IssuerEndpoint>(),
tags: new[] { "Beta", "beta ", "Alpha" },
displayName: "Red Hat Security",
description: " Updated ",
updatedAtUtc: DateTimeOffset.Parse("2025-11-02T00:00:00Z"),
updatedBy: "editor");
updated.Description.Should().Be("Updated");
updated.Tags.Should().Equal("beta", "alpha");
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using StellaOps.IssuerDirectory.Client;
namespace StellaOps.IssuerDirectory.Core.Tests;
public partial class IssuerDirectoryClientTests
{
private static IIssuerDirectoryClient CreateClient(
RecordingHandler handler,
IssuerDirectoryClientOptions? options = null)
{
var opts = options ?? DefaultOptions();
var httpClient = new HttpClient(handler)
{
BaseAddress = opts.BaseAddress
};
var memoryCache = new MemoryCache(new MemoryCacheOptions());
var clientOptions = Options.Create(opts);
var clientType = typeof(IssuerDirectoryClientOptions)
.Assembly
.GetType("StellaOps.IssuerDirectory.Client.IssuerDirectoryClient", throwOnError: true)!;
var loggerType = typeof(TestLogger<>).MakeGenericType(clientType);
var logger = Activator.CreateInstance(loggerType)!;
var instance = Activator.CreateInstance(
clientType,
new object[] { httpClient, memoryCache, clientOptions, logger });
return (IIssuerDirectoryClient)instance!;
}
private static IssuerDirectoryClientOptions DefaultOptions()
{
return new IssuerDirectoryClientOptions
{
BaseAddress = new Uri("https://issuer-directory.local/"),
TenantHeader = "X-StellaOps-Tenant",
AuditReasonHeader = "X-StellaOps-Reason"
};
}
private static HttpResponseMessage CreateJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Net.Http;
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests;
public partial class IssuerDirectoryClientTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetIssuerKeysAsync_SendsTenantHeaderAndCachesByIncludeGlobalAsync()
{
var handler = new RecordingHandler(
CreateJsonResponse("""[{"id":"key-1","issuerId":"issuer-1","tenantId":"tenant-a","type":"ed25519","status":"active","materialFormat":"base64","materialValue":"AQ==","fingerprint":"fp-1","expiresAtUtc":null,"retiredAtUtc":null,"revokedAtUtc":null,"replacesKeyId":null}]"""),
CreateJsonResponse("""[{"id":"key-2","issuerId":"issuer-1","tenantId":"tenant-a","type":"ed25519","status":"active","materialFormat":"base64","materialValue":"AQ==","fingerprint":"fp-2","expiresAtUtc":null,"retiredAtUtc":null,"revokedAtUtc":null,"replacesKeyId":null}]"""));
var client = CreateClient(handler);
var first = await client.GetIssuerKeysAsync(" tenant-a ", "issuer-1 ", includeGlobal: false, CancellationToken.None);
first.Should().HaveCount(1);
handler.Requests.Should().HaveCount(1);
var firstRequest = handler.Requests[0];
firstRequest.Method.Should().Be(HttpMethod.Get);
firstRequest.Uri.Should().Be(new Uri("https://issuer-directory.local/issuer-directory/issuers/issuer-1/keys?includeGlobal=false"));
firstRequest.Headers.TryGetValue("X-StellaOps-Tenant", out var tenantValues).Should().BeTrue();
tenantValues!.Should().Equal("tenant-a");
var cached = await client.GetIssuerKeysAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
cached.Should().HaveCount(1);
handler.Requests.Should().HaveCount(1);
var global = await client.GetIssuerKeysAsync("tenant-a", "issuer-1", includeGlobal: true, CancellationToken.None);
global.Should().HaveCount(1);
handler.Requests.Should().HaveCount(2);
handler.Requests[1].Uri.Should().Be(new Uri("https://issuer-directory.local/issuer-directory/issuers/issuer-1/keys?includeGlobal=true"));
}
}

View File

@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.IssuerDirectory.Core.Tests;
public partial class IssuerDirectoryClientTests
{
private sealed record RecordedRequest(HttpMethod Method, Uri Uri, IDictionary<string, string[]> Headers, string? Body);
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses;
public RecordingHandler(params HttpResponseMessage[] responses)
{
_responses = new Queue<HttpResponseMessage>(responses);
}
public List<RecordedRequest> Requests { get; } = new();
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string? body = null;
if (request.Content is not null)
{
body = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
var headers = request.Headers.ToDictionary(pair => pair.Key, pair => pair.Value.ToArray());
if (request.Content?.Headers is not null)
{
foreach (var header in request.Content.Headers)
{
headers[header.Key] = header.Value.ToArray();
}
}
Requests.Add(new RecordedRequest(request.Method, request.RequestUri!, headers, body));
if (_responses.Count == 0)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json")
};
}
return _responses.Dequeue();
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.IssuerDirectory.Core.Tests;
public partial class IssuerDirectoryClientTests
{
private sealed class TestLogger<T> : ILogger<T>
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullDisposable.Instance;
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
}
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Net;
using System.Net.Http;
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests;
public partial class IssuerDirectoryClientTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DeleteIssuerTrustAsync_UsesDeleteVerbAndReasonHeaderWhenProvidedAsync()
{
var handler = new RecordingHandler(
CreateJsonResponse("""{"tenantOverride":{"weight":2.0,"reason":"seed","updatedAtUtc":"2025-11-02T00:00:00Z","updatedBy":"actor","createdAtUtc":"2025-11-02T00:00:00Z","createdBy":"actor"},"globalOverride":null,"effectiveWeight":2.0}"""),
new HttpResponseMessage(HttpStatusCode.NoContent),
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""));
var client = CreateClient(handler);
await client.GetIssuerTrustAsync("tenant-b", "issuer-9", includeGlobal: true, CancellationToken.None);
handler.Requests.Should().HaveCount(1);
await client.DeleteIssuerTrustAsync("tenant-b", "issuer-9", " cleanup ", CancellationToken.None);
handler.Requests.Should().HaveCount(2);
var deleteRequest = handler.Requests[1];
deleteRequest.Method.Should().Be(HttpMethod.Delete);
deleteRequest.Uri.Should().Be(new Uri("https://issuer-directory.local/issuer-directory/issuers/issuer-9/trust"));
deleteRequest.Headers.TryGetValue("X-StellaOps-Tenant", out var tenantValues).Should().BeTrue();
tenantValues!.Should().Equal("tenant-b");
deleteRequest.Headers.TryGetValue("X-StellaOps-Reason", out var reasonValues).Should().BeTrue();
reasonValues!.Should().Equal("cleanup");
deleteRequest.Body.Should().BeNull();
await client.GetIssuerTrustAsync("tenant-b", "issuer-9", includeGlobal: true, CancellationToken.None);
handler.Requests.Should().HaveCount(3);
handler.Requests[2].Method.Should().Be(HttpMethod.Get);
}
}

View File

@@ -0,0 +1,39 @@
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests;
public partial class IssuerDirectoryClientTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SetIssuerTrustAsync_PropagatesFailureAndDoesNotEvictCacheAsync()
{
var handler = new RecordingHandler(
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""),
new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json")
});
var client = CreateClient(handler);
var cached = await client.GetIssuerTrustAsync("tenant-c", "issuer-err", includeGlobal: false, CancellationToken.None);
cached.EffectiveWeight.Should().Be(0m);
handler.Requests.Should().HaveCount(1);
await FluentActions.Invoking(() => client.SetIssuerTrustAsync("tenant-c", "issuer-err", 0.5m, null, CancellationToken.None).AsTask())
.Should().ThrowAsync<HttpRequestException>();
handler.Requests.Should().HaveCount(2);
await client.GetIssuerTrustAsync("tenant-c", "issuer-err", includeGlobal: false, CancellationToken.None);
handler.Requests.Should().HaveCount(2, "cache should remain warm after failure");
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Net.Http;
using System.Text.Json;
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests;
public partial class IssuerDirectoryClientTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SetIssuerTrustAsync_SendsAuditMetadataAndInvalidatesCacheAsync()
{
var handler = new RecordingHandler(
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""),
CreateJsonResponse("""{"tenantOverride":{"weight":1.5,"reason":"rollout","updatedAtUtc":"2025-11-03T00:00:00Z","updatedBy":"actor","createdAtUtc":"2025-11-03T00:00:00Z","createdBy":"actor"},"globalOverride":null,"effectiveWeight":1.5}"""),
CreateJsonResponse("""{"tenantOverride":{"weight":1.5,"reason":"rollout","updatedAtUtc":"2025-11-03T00:00:00Z","updatedBy":"actor","createdAtUtc":"2025-11-03T00:00:00Z","createdBy":"actor"},"globalOverride":null,"effectiveWeight":1.5}"""));
var client = CreateClient(handler);
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
handler.Requests.Should().HaveCount(1);
var result = await client.SetIssuerTrustAsync(" tenant-a ", " issuer-1 ", 1.5m, " rollout ", CancellationToken.None);
result.EffectiveWeight.Should().Be(1.5m);
handler.Requests.Should().HaveCount(2);
var putRequest = handler.Requests[1];
putRequest.Method.Should().Be(HttpMethod.Put);
putRequest.Uri.Should().Be(new Uri("https://issuer-directory.local/issuer-directory/issuers/issuer-1/trust"));
putRequest.Headers.TryGetValue("X-StellaOps-Tenant", out var tenantValues).Should().BeTrue();
tenantValues!.Should().Equal("tenant-a");
putRequest.Headers.TryGetValue("X-StellaOps-Reason", out var reasonValues).Should().BeTrue();
reasonValues!.Should().Equal("rollout");
using var document = JsonDocument.Parse(putRequest.Body ?? string.Empty);
var root = document.RootElement;
root.GetProperty("weight").GetDecimal().Should().Be(1.5m);
root.GetProperty("reason").GetString().Should().Be("rollout");
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
handler.Requests.Should().HaveCount(3);
handler.Requests[2].Method.Should().Be(HttpMethod.Get);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SetIssuerTrustAsync_InvalidatesBothCacheVariantsAsync()
{
var handler = new RecordingHandler(
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""),
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""),
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""),
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""),
CreateJsonResponse("""{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}"""));
var client = CreateClient(handler);
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: true, CancellationToken.None);
handler.Requests.Should().HaveCount(2);
await client.SetIssuerTrustAsync("tenant-a", "issuer-1", 1m, null, CancellationToken.None);
handler.Requests.Should().HaveCount(3);
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: true, CancellationToken.None);
handler.Requests.Should().HaveCount(5);
handler.Requests[3].Method.Should().Be(HttpMethod.Get);
handler.Requests[4].Method.Should().Be(HttpMethod.Get);
}
}

View File

@@ -1,244 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using FluentAssertions.Specialized;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.IssuerDirectory.Client;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.IssuerDirectory.Core.Tests;
public class IssuerDirectoryClientTests
{
private static IIssuerDirectoryClient CreateClient(RecordingHandler handler, IssuerDirectoryClientOptions? options = null)
{
var opts = options ?? DefaultOptions();
var httpClient = new HttpClient(handler)
{
BaseAddress = opts.BaseAddress
};
var memoryCache = new MemoryCache(new MemoryCacheOptions());
var clientOptions = Options.Create(opts);
var clientType = typeof(IssuerDirectoryClientOptions)
.Assembly
.GetType("StellaOps.IssuerDirectory.Client.IssuerDirectoryClient", throwOnError: true)!;
var loggerType = typeof(TestLogger<>).MakeGenericType(clientType);
var logger = Activator.CreateInstance(loggerType)!;
var instance = Activator.CreateInstance(
clientType,
new object[] { httpClient, memoryCache, clientOptions, logger });
return (IIssuerDirectoryClient)instance!;
}
private static IssuerDirectoryClientOptions DefaultOptions()
{
return new IssuerDirectoryClientOptions
{
BaseAddress = new Uri("https://issuer-directory.local/"),
TenantHeader = "X-StellaOps-Tenant",
AuditReasonHeader = "X-StellaOps-Reason"
};
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SetIssuerTrustAsync_SendsAuditMetadataAndInvalidatesCache()
{
var handler = new RecordingHandler(
CreateJsonResponse("""
{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}
"""),
CreateJsonResponse("""
{"tenantOverride":{"weight":1.5,"reason":"rollout","updatedAtUtc":"2025-11-03T00:00:00Z","updatedBy":"actor","createdAtUtc":"2025-11-03T00:00:00Z","createdBy":"actor"},"globalOverride":null,"effectiveWeight":1.5}
"""),
CreateJsonResponse("""
{"tenantOverride":{"weight":1.5,"reason":"rollout","updatedAtUtc":"2025-11-03T00:00:00Z","updatedBy":"actor","createdAtUtc":"2025-11-03T00:00:00Z","createdBy":"actor"},"globalOverride":null,"effectiveWeight":1.5}
"""));
var client = CreateClient(handler);
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
handler.Requests.Should().HaveCount(1);
var result = await client.SetIssuerTrustAsync("tenant-a", "issuer-1", 1.5m, "rollout", CancellationToken.None);
result.EffectiveWeight.Should().Be(1.5m);
handler.Requests.Should().HaveCount(2);
var putRequest = handler.Requests[1];
putRequest.Method.Should().Be(HttpMethod.Put);
putRequest.Uri.Should().Be(new Uri("https://issuer-directory.local/issuer-directory/issuers/issuer-1/trust"));
putRequest.Headers.TryGetValue("X-StellaOps-Tenant", out var tenantValues).Should().BeTrue();
tenantValues.Should().NotBeNull();
tenantValues!.Should().Equal("tenant-a");
putRequest.Headers.TryGetValue("X-StellaOps-Reason", out var reasonValues).Should().BeTrue();
reasonValues.Should().NotBeNull();
reasonValues!.Should().Equal("rollout");
using var document = JsonDocument.Parse(putRequest.Body ?? string.Empty);
var root = document.RootElement;
root.GetProperty("weight").GetDecimal().Should().Be(1.5m);
root.GetProperty("reason").GetString().Should().Be("rollout");
await client.GetIssuerTrustAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
handler.Requests.Should().HaveCount(3);
handler.Requests[2].Method.Should().Be(HttpMethod.Get);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DeleteIssuerTrustAsync_UsesDeleteVerbAndReasonHeaderWhenProvided()
{
var handler = new RecordingHandler(
CreateJsonResponse("""
{"tenantOverride":{"weight":2.0,"reason":"seed","updatedAtUtc":"2025-11-02T00:00:00Z","updatedBy":"actor","createdAtUtc":"2025-11-02T00:00:00Z","createdBy":"actor"},"globalOverride":null,"effectiveWeight":2.0}
"""),
new HttpResponseMessage(HttpStatusCode.NoContent),
CreateJsonResponse("""
{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}
"""));
var client = CreateClient(handler);
await client.GetIssuerTrustAsync("tenant-b", "issuer-9", includeGlobal: true, CancellationToken.None);
handler.Requests.Should().HaveCount(1);
await client.DeleteIssuerTrustAsync("tenant-b", "issuer-9", null, CancellationToken.None);
handler.Requests.Should().HaveCount(2);
var deleteRequest = handler.Requests[1];
deleteRequest.Method.Should().Be(HttpMethod.Delete);
deleteRequest.Uri.Should().Be(new Uri("https://issuer-directory.local/issuer-directory/issuers/issuer-9/trust"));
deleteRequest.Headers.ContainsKey("X-StellaOps-Tenant").Should().BeTrue();
deleteRequest.Headers.ContainsKey("X-StellaOps-Reason").Should().BeFalse();
deleteRequest.Body.Should().BeNull();
await client.GetIssuerTrustAsync("tenant-b", "issuer-9", includeGlobal: true, CancellationToken.None);
handler.Requests.Should().HaveCount(3);
handler.Requests[2].Method.Should().Be(HttpMethod.Get);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SetIssuerTrustAsync_PropagatesFailureAndDoesNotEvictCache()
{
var handler = new RecordingHandler(
CreateJsonResponse("""
{"tenantOverride":null,"globalOverride":null,"effectiveWeight":0}
"""),
new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json")
});
var client = CreateClient(handler);
var cached = await client.GetIssuerTrustAsync("tenant-c", "issuer-err", includeGlobal: false, CancellationToken.None);
cached.EffectiveWeight.Should().Be(0m);
handler.Requests.Should().HaveCount(1);
await FluentActions.Invoking(() => client.SetIssuerTrustAsync("tenant-c", "issuer-err", 0.5m, null, CancellationToken.None).AsTask())
.Should().ThrowAsync<HttpRequestException>();
handler.Requests.Should().HaveCount(2);
await client.GetIssuerTrustAsync("tenant-c", "issuer-err", includeGlobal: false, CancellationToken.None);
handler.Requests.Should().HaveCount(2, "cache should remain warm after failure");
}
private static HttpResponseMessage CreateJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
private sealed record RecordedRequest(HttpMethod Method, Uri Uri, IDictionary<string, string[]> Headers, string? Body);
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses;
public RecordingHandler(params HttpResponseMessage[] responses)
{
_responses = new Queue<HttpResponseMessage>(responses);
}
public List<RecordedRequest> Requests { get; } = new();
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string? body = null;
if (request.Content is not null)
{
body = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
var headers = request.Headers.ToDictionary(
pair => pair.Key,
pair => pair.Value.ToArray());
if (request.Content?.Headers is not null)
{
foreach (var header in request.Content.Headers)
{
headers[header.Key] = header.Value.ToArray();
}
}
Requests.Add(new RecordedRequest(request.Method, request.RequestUri!, headers, body));
if (_responses.Count == 0)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json")
};
}
return _responses.Dequeue();
}
}
private sealed class TestLogger<T> : ILogger<T>
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullDisposable.Instance;
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
}
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,76 @@
using FluentAssertions;
using StellaOps.IssuerDirectory.Core.Domain;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
public partial class IssuerDirectoryServiceTests
{
[Fact]
public async Task CreateAsync_PersistsIssuerAndAuditEntryAsync()
{
var issuer = await _service.CreateAsync(
tenantId: "tenant-a",
issuerId: "red-hat",
displayName: "Red Hat",
slug: "red-hat",
description: "Vendor",
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com"), null),
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/cve"), null, new[] { "en" }, null),
endpoints: new[] { new IssuerEndpoint("csaf", new Uri("https://example.com/csaf"), "csaf", false) },
tags: new[] { "vendor" },
actor: "tester",
reason: "initial",
cancellationToken: CancellationToken.None);
var stored = await _repository.GetAsync("tenant-a", "red-hat", CancellationToken.None);
stored.Should().NotBeNull();
stored!.DisplayName.Should().Be("Red Hat");
stored.CreatedBy.Should().Be("tester");
_auditSink.Entries.Should().ContainSingle(entry => entry.Action == "created" && entry.TenantId == "tenant-a");
issuer.CreatedAtUtc.Should().Be(_timeProvider.GetUtcNow());
}
[Fact]
public async Task UpdateAsync_ReplacesMetadataAndRecordsAuditAsync()
{
await CreateSampleAsync();
_timeProvider.Advance(TimeSpan.FromHours(1));
var updated = await _service.UpdateAsync(
tenantId: "tenant-a",
issuerId: "red-hat",
displayName: "Red Hat Security",
description: "Updated vendor",
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com/security"), null),
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/new"), null, new[] { "en", "de" }, null),
endpoints: new[] { new IssuerEndpoint("csaf", new Uri("https://example.com/csaf"), "csaf", false) },
tags: new[] { "vendor", "trusted" },
actor: "editor",
reason: "update",
cancellationToken: CancellationToken.None);
updated.DisplayName.Should().Be("Red Hat Security");
updated.Tags.Should().Contain(new[] { "vendor", "trusted" });
updated.UpdatedBy.Should().Be("editor");
updated.UpdatedAtUtc.Should().Be(_timeProvider.GetUtcNow());
_auditSink.Entries.Should().Contain(entry => entry.Action == "updated");
}
[Fact]
public async Task DeleteAsync_RemovesIssuerAndWritesAuditAsync()
{
await CreateSampleAsync();
await _service.DeleteAsync("tenant-a", "red-hat", "deleter", "cleanup", CancellationToken.None);
var stored = await _repository.GetAsync("tenant-a", "red-hat", CancellationToken.None);
stored.Should().BeNull();
_auditSink.Entries.Should().Contain(entry => entry.Action == "deleted" && entry.Actor == "deleter");
}
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
using System.Linq;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
public partial class IssuerDirectoryServiceTests
{
private sealed class FakeIssuerRepository : IIssuerRepository
{
private readonly ConcurrentDictionary<(string Tenant, string Id), IssuerRecord> _store = new();
public Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryGetValue((tenantId, issuerId), out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
var results = _store
.Where(pair => pair.Key.Tenant.Equals(tenantId, StringComparison.Ordinal))
.Select(pair => pair.Value)
.ToArray();
return Task.FromResult((IReadOnlyCollection<IssuerRecord>)results);
}
public Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
{
var results = _store
.Where(pair => pair.Key.Tenant.Equals(IssuerTenants.Global, StringComparison.Ordinal))
.Select(pair => pair.Value)
.ToArray();
return Task.FromResult((IReadOnlyCollection<IssuerRecord>)results);
}
public Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
{
_store[(record.TenantId, record.Id)] = record;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryRemove((tenantId, issuerId), out _);
return Task.CompletedTask;
}
}
private sealed class FakeIssuerAuditSink : IIssuerAuditSink
{
private readonly ConcurrentBag<IssuerAuditEntry> _entries = new();
public IReadOnlyCollection<IssuerAuditEntry> Entries => _entries.ToArray();
public Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
{
_entries.Add(entry);
return Task.CompletedTask;
}
public void Clear() => _entries.Clear();
}
}

View File

@@ -0,0 +1,37 @@
using FluentAssertions;
using StellaOps.IssuerDirectory.Core.Domain;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
public partial class IssuerDirectoryServiceTests
{
[Fact]
public async Task SeedAsync_InsertsOnlyMissingSeedsAsync()
{
var seedRecord = IssuerRecord.Create(
id: "red-hat",
tenantId: IssuerTenants.Global,
displayName: "Red Hat",
slug: "red-hat",
description: null,
contact: new IssuerContact(null, null, null, null),
metadata: new IssuerMetadata(null, null, null, null, Array.Empty<string>(), null),
endpoints: Array.Empty<IssuerEndpoint>(),
tags: Array.Empty<string>(),
timestampUtc: _timeProvider.GetUtcNow(),
actor: "seed",
isSystemSeed: true);
await _service.SeedAsync(new[] { seedRecord }, CancellationToken.None);
_auditSink.Entries.Should().Contain(entry => entry.Action == "seeded");
_auditSink.Clear();
_timeProvider.Advance(TimeSpan.FromMinutes(10));
await _service.SeedAsync(new[] { seedRecord }, CancellationToken.None);
_auditSink.Entries.Should().BeEmpty("existing seeds should not emit duplicate audit entries");
}
}

View File

@@ -1,15 +1,14 @@
using System.Collections.Concurrent;
using FluentAssertions;
using System;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Services;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
public class IssuerDirectoryServiceTests
public partial class IssuerDirectoryServiceTests
{
private readonly FakeIssuerRepository _repository = new();
private readonly FakeIssuerAuditSink _auditSink = new();
@@ -21,99 +20,6 @@ public class IssuerDirectoryServiceTests
_service = new IssuerDirectoryService(_repository, _auditSink, _timeProvider, NullLogger<IssuerDirectoryService>.Instance);
}
[Fact]
public async Task CreateAsync_PersistsIssuerAndAuditEntry()
{
var issuer = await _service.CreateAsync(
tenantId: "tenant-a",
issuerId: "red-hat",
displayName: "Red Hat",
slug: "red-hat",
description: "Vendor",
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com"), null),
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/cve"), null, new[] { "en" }, null),
endpoints: new[] { new IssuerEndpoint("csaf", new Uri("https://example.com/csaf"), "csaf", false) },
tags: new[] { "vendor" },
actor: "tester",
reason: "initial",
cancellationToken: CancellationToken.None);
var stored = await _repository.GetAsync("tenant-a", "red-hat", CancellationToken.None);
stored.Should().NotBeNull();
stored!.DisplayName.Should().Be("Red Hat");
stored.CreatedBy.Should().Be("tester");
_auditSink.Entries.Should().ContainSingle(entry => entry.Action == "created" && entry.TenantId == "tenant-a");
issuer.CreatedAtUtc.Should().Be(_timeProvider.GetUtcNow());
}
[Fact]
public async Task UpdateAsync_ReplacesMetadataAndRecordsAudit()
{
await CreateSampleAsync();
_timeProvider.Advance(TimeSpan.FromHours(1));
var updated = await _service.UpdateAsync(
tenantId: "tenant-a",
issuerId: "red-hat",
displayName: "Red Hat Security",
description: "Updated vendor",
contact: new IssuerContact("sec@example.com", null, new Uri("https://example.com/security"), null),
metadata: new IssuerMetadata("org", "publisher", new Uri("https://example.com/new"), null, new[] { "en", "de" }, null),
endpoints: new[] { new IssuerEndpoint("csaf", new Uri("https://example.com/csaf"), "csaf", false) },
tags: new[] { "vendor", "trusted" },
actor: "editor",
reason: "update",
cancellationToken: CancellationToken.None);
updated.DisplayName.Should().Be("Red Hat Security");
updated.Tags.Should().Contain(new[] { "vendor", "trusted" });
updated.UpdatedBy.Should().Be("editor");
updated.UpdatedAtUtc.Should().Be(_timeProvider.GetUtcNow());
_auditSink.Entries.Should().Contain(entry => entry.Action == "updated");
}
[Fact]
public async Task DeleteAsync_RemovesIssuerAndWritesAudit()
{
await CreateSampleAsync();
await _service.DeleteAsync("tenant-a", "red-hat", "deleter", "cleanup", CancellationToken.None);
var stored = await _repository.GetAsync("tenant-a", "red-hat", CancellationToken.None);
stored.Should().BeNull();
_auditSink.Entries.Should().Contain(entry => entry.Action == "deleted" && entry.Actor == "deleter");
}
[Fact]
public async Task SeedAsync_InsertsOnlyMissingSeeds()
{
var seedRecord = IssuerRecord.Create(
id: "red-hat",
tenantId: IssuerTenants.Global,
displayName: "Red Hat",
slug: "red-hat",
description: null,
contact: new IssuerContact(null, null, null, null),
metadata: new IssuerMetadata(null, null, null, null, Array.Empty<string>(), null),
endpoints: Array.Empty<IssuerEndpoint>(),
tags: Array.Empty<string>(),
timestampUtc: _timeProvider.GetUtcNow(),
actor: "seed",
isSystemSeed: true);
await _service.SeedAsync(new[] { seedRecord }, CancellationToken.None);
_auditSink.Entries.Should().Contain(entry => entry.Action == "seeded");
_auditSink.Clear();
_timeProvider.Advance(TimeSpan.FromMinutes(10));
await _service.SeedAsync(new[] { seedRecord }, CancellationToken.None);
_auditSink.Entries.Should().BeEmpty("existing seeds should not emit duplicate audit entries");
}
private async Task CreateSampleAsync()
{
await _service.CreateAsync(
@@ -132,60 +38,4 @@ public class IssuerDirectoryServiceTests
_auditSink.Clear();
}
private sealed class FakeIssuerRepository : IIssuerRepository
{
private readonly ConcurrentDictionary<(string Tenant, string Id), IssuerRecord> _store = new();
public Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryGetValue((tenantId, issuerId), out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
var results = _store
.Where(pair => pair.Key.Tenant.Equals(tenantId, StringComparison.Ordinal))
.Select(pair => pair.Value)
.ToArray();
return Task.FromResult((IReadOnlyCollection<IssuerRecord>)results);
}
public Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
{
var results = _store
.Where(pair => pair.Key.Tenant.Equals(IssuerTenants.Global, StringComparison.Ordinal))
.Select(pair => pair.Value)
.ToArray();
return Task.FromResult((IReadOnlyCollection<IssuerRecord>)results);
}
public Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
{
_store[(record.TenantId, record.Id)] = record;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryRemove((tenantId, issuerId), out _);
return Task.CompletedTask;
}
}
private sealed class FakeIssuerAuditSink : IIssuerAuditSink
{
private readonly ConcurrentBag<IssuerAuditEntry> _entries = new();
public IReadOnlyCollection<IssuerAuditEntry> Entries => _entries.ToArray();
public Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
{
_entries.Add(entry);
return Task.CompletedTask;
}
public void Clear() => _entries.Clear();
}
}

View File

@@ -0,0 +1,104 @@
using System.Collections.Concurrent;
using System.Linq;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
public partial class IssuerKeyServiceTests
{
private sealed class FakeIssuerRepository : IIssuerRepository
{
private readonly ConcurrentDictionary<(string Tenant, string Id), IssuerRecord> _store = new();
public void Add(IssuerRecord record)
{
_store[(record.TenantId, record.Id)] = record;
}
public Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryGetValue((tenantId, issuerId), out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
{
_store[(record.TenantId, record.Id)] = record;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryRemove((tenantId, issuerId), out _);
return Task.CompletedTask;
}
}
private sealed class FakeIssuerKeyRepository : IIssuerKeyRepository
{
private readonly ConcurrentDictionary<(string Tenant, string Issuer, string KeyId), IssuerKeyRecord> _store = new();
public Task<IssuerKeyRecord?> GetAsync(string tenantId, string issuerId, string keyId, CancellationToken cancellationToken)
{
_store.TryGetValue((tenantId, issuerId, keyId), out var value);
return Task.FromResult(value);
}
public Task<IssuerKeyRecord?> GetByFingerprintAsync(string tenantId, string issuerId, string fingerprint, CancellationToken cancellationToken)
{
var record = _store.Values.FirstOrDefault(key => key.TenantId == tenantId && key.IssuerId == issuerId && key.Fingerprint == fingerprint);
return Task.FromResult(record);
}
public Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
var records = _store
.Where(pair => pair.Key.Tenant == tenantId && pair.Key.Issuer == issuerId)
.Select(pair => pair.Value)
.ToArray();
return Task.FromResult((IReadOnlyCollection<IssuerKeyRecord>)records);
}
public Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(string issuerId, CancellationToken cancellationToken)
{
var records = _store
.Where(pair => pair.Key.Tenant == IssuerTenants.Global && pair.Key.Issuer == issuerId)
.Select(pair => pair.Value)
.ToArray();
return Task.FromResult((IReadOnlyCollection<IssuerKeyRecord>)records);
}
public Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken)
{
_store[(record.TenantId, record.IssuerId, record.Id)] = record;
return Task.CompletedTask;
}
}
private sealed class FakeIssuerAuditSink : IIssuerAuditSink
{
private readonly ConcurrentBag<IssuerAuditEntry> _entries = new();
public IReadOnlyCollection<IssuerAuditEntry> Entries => _entries.ToArray();
public Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
{
_entries.Add(entry);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,101 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Services;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
public partial class IssuerKeyServiceTests
{
[Fact]
public async Task AddAsync_StoresKeyAndWritesAuditAsync()
{
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32]));
var record = await _service.AddAsync(
tenantId: "tenant-a",
issuerId: "red-hat",
type: IssuerKeyType.Ed25519PublicKey,
material,
expiresAtUtc: null,
actor: "tester",
reason: "initial",
cancellationToken: CancellationToken.None);
record.Status.Should().Be(IssuerKeyStatus.Active);
record.Fingerprint.Should().NotBeNullOrWhiteSpace();
_auditSink.Entries.Should().Contain(entry => entry.Action == "key_created");
}
[Fact]
public async Task AddAsync_DuplicateFingerprint_ThrowsAsync()
{
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32]));
await _service.AddAsync("tenant-a", "red-hat", IssuerKeyType.Ed25519PublicKey, material, null, "tester", null, CancellationToken.None);
var action = async () => await _service.AddAsync("tenant-a", "red-hat", IssuerKeyType.Ed25519PublicKey, material, null, "tester", null, CancellationToken.None);
await action.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task AddAsync_MissingIssuer_ThrowsAsync()
{
var issuerRepository = new FakeIssuerRepository();
var keyRepository = new FakeIssuerKeyRepository();
var auditSink = new FakeIssuerAuditSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T12:00:00Z"));
var service = new IssuerKeyService(
issuerRepository,
keyRepository,
auditSink,
timeProvider,
NullLogger<IssuerKeyService>.Instance);
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32]));
var action = async () => await service.AddAsync(
"tenant-a",
"missing",
IssuerKeyType.Ed25519PublicKey,
material,
null,
"tester",
null,
CancellationToken.None);
await action.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task RotateAsync_RetiresOldKeyAndCreatesReplacementAsync()
{
var originalMaterial = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32 }));
var original = await _service.AddAsync("tenant-a", "red-hat", IssuerKeyType.Ed25519PublicKey, originalMaterial, null, "tester", null, CancellationToken.None);
var newMaterial = new IssuerKeyMaterial("base64", Convert.ToBase64String(Enumerable.Repeat<byte>(99, 32).ToArray()));
var replacement = await _service.RotateAsync("tenant-a", "red-hat", original.Id, IssuerKeyType.Ed25519PublicKey, newMaterial, null, "tester", "rotation", CancellationToken.None);
replacement.ReplacesKeyId.Should().Be(original.Id);
var retired = await _keyRepository.GetAsync("tenant-a", "red-hat", original.Id, CancellationToken.None);
retired!.Status.Should().Be(IssuerKeyStatus.Retired);
}
[Fact]
public async Task RevokeAsync_SetsStatusToRevokedAsync()
{
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(Enumerable.Repeat<byte>(77, 32).ToArray()));
var key = await _service.AddAsync("tenant-a", "red-hat", IssuerKeyType.Ed25519PublicKey, material, null, "tester", null, CancellationToken.None);
await _service.RevokeAsync("tenant-a", "red-hat", key.Id, "tester", "compromised", CancellationToken.None);
var revoked = await _keyRepository.GetAsync("tenant-a", "red-hat", key.Id, CancellationToken.None);
revoked!.Status.Should().Be(IssuerKeyStatus.Revoked);
_auditSink.Entries.Should().Contain(entry => entry.Action == "key_revoked");
}
}

View File

@@ -1,15 +1,14 @@
using System.Collections.Concurrent;
using FluentAssertions;
using System;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Services;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
public class IssuerKeyServiceTests
public partial class IssuerKeyServiceTests
{
private readonly FakeIssuerRepository _issuerRepository = new();
private readonly FakeIssuerKeyRepository _keyRepository = new();
@@ -42,157 +41,4 @@ public class IssuerKeyServiceTests
_issuerRepository.Add(issuer);
}
[Fact]
public async Task AddAsync_StoresKeyAndWritesAudit()
{
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32]));
var record = await _service.AddAsync(
tenantId: "tenant-a",
issuerId: "red-hat",
type: IssuerKeyType.Ed25519PublicKey,
material,
expiresAtUtc: null,
actor: "tester",
reason: "initial",
cancellationToken: CancellationToken.None);
record.Status.Should().Be(IssuerKeyStatus.Active);
record.Fingerprint.Should().NotBeNullOrWhiteSpace();
_auditSink.Entries.Should().Contain(entry => entry.Action == "key_created");
}
[Fact]
public async Task AddAsync_DuplicateFingerprint_Throws()
{
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32]));
await _service.AddAsync("tenant-a", "red-hat", IssuerKeyType.Ed25519PublicKey, material, null, "tester", null, CancellationToken.None);
var action = async () => await _service.AddAsync("tenant-a", "red-hat", IssuerKeyType.Ed25519PublicKey, material, null, "tester", null, CancellationToken.None);
await action.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task RotateAsync_RetiresOldKeyAndCreatesReplacement()
{
var originalMaterial = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[32] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32 }));
var original = await _service.AddAsync("tenant-a", "red-hat", IssuerKeyType.Ed25519PublicKey, originalMaterial, null, "tester", null, CancellationToken.None);
var newMaterial = new IssuerKeyMaterial("base64", Convert.ToBase64String(Enumerable.Repeat<byte>(99, 32).ToArray()));
var replacement = await _service.RotateAsync("tenant-a", "red-hat", original.Id, IssuerKeyType.Ed25519PublicKey, newMaterial, null, "tester", "rotation", CancellationToken.None);
replacement.ReplacesKeyId.Should().Be(original.Id);
var retired = await _keyRepository.GetAsync("tenant-a", "red-hat", original.Id, CancellationToken.None);
retired!.Status.Should().Be(IssuerKeyStatus.Retired);
}
[Fact]
public async Task RevokeAsync_SetsStatusToRevoked()
{
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(Enumerable.Repeat<byte>(77, 32).ToArray()));
var key = await _service.AddAsync("tenant-a", "red-hat", IssuerKeyType.Ed25519PublicKey, material, null, "tester", null, CancellationToken.None);
await _service.RevokeAsync("tenant-a", "red-hat", key.Id, "tester", "compromised", CancellationToken.None);
var revoked = await _keyRepository.GetAsync("tenant-a", "red-hat", key.Id, CancellationToken.None);
revoked!.Status.Should().Be(IssuerKeyStatus.Revoked);
_auditSink.Entries.Should().Contain(entry => entry.Action == "key_revoked");
}
private sealed class FakeIssuerRepository : IIssuerRepository
{
private readonly ConcurrentDictionary<(string Tenant, string Id), IssuerRecord> _store = new();
public void Add(IssuerRecord record)
{
_store[(record.TenantId, record.Id)] = record;
}
public Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryGetValue((tenantId, issuerId), out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
{
_store[(record.TenantId, record.Id)] = record;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryRemove((tenantId, issuerId), out _);
return Task.CompletedTask;
}
}
private sealed class FakeIssuerKeyRepository : IIssuerKeyRepository
{
private readonly ConcurrentDictionary<(string Tenant, string Issuer, string KeyId), IssuerKeyRecord> _store = new();
public Task<IssuerKeyRecord?> GetAsync(string tenantId, string issuerId, string keyId, CancellationToken cancellationToken)
{
_store.TryGetValue((tenantId, issuerId, keyId), out var value);
return Task.FromResult(value);
}
public Task<IssuerKeyRecord?> GetByFingerprintAsync(string tenantId, string issuerId, string fingerprint, CancellationToken cancellationToken)
{
var record = _store.Values.FirstOrDefault(key => key.TenantId == tenantId && key.IssuerId == issuerId && key.Fingerprint == fingerprint);
return Task.FromResult(record);
}
public Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
var records = _store
.Where(pair => pair.Key.Tenant == tenantId && pair.Key.Issuer == issuerId)
.Select(pair => pair.Value)
.ToArray();
return Task.FromResult((IReadOnlyCollection<IssuerKeyRecord>)records);
}
public Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(string issuerId, CancellationToken cancellationToken)
{
var records = _store
.Where(pair => pair.Key.Tenant == IssuerTenants.Global && pair.Key.Issuer == issuerId)
.Select(pair => pair.Value)
.ToArray();
return Task.FromResult((IReadOnlyCollection<IssuerKeyRecord>)records);
}
public Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken)
{
_store[(record.TenantId, record.IssuerId, record.Id)] = record;
return Task.CompletedTask;
}
}
private sealed class FakeIssuerAuditSink : IIssuerAuditSink
{
private readonly ConcurrentBag<IssuerAuditEntry> _entries = new();
public IReadOnlyCollection<IssuerAuditEntry> Entries => _entries.ToArray();
public Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
{
_entries.Add(entry);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,80 @@
using System.Collections.Concurrent;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
public partial class IssuerTrustServiceTests
{
private sealed class FakeIssuerRepository : IIssuerRepository
{
private readonly ConcurrentDictionary<(string Tenant, string Id), IssuerRecord> _store = new();
public void Add(IssuerRecord record) => _store[(record.TenantId, record.Id)] = record;
public Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryGetValue((tenantId, issuerId), out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
{
_store[(record.TenantId, record.Id)] = record;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryRemove((tenantId, issuerId), out _);
return Task.CompletedTask;
}
}
private sealed class FakeIssuerTrustRepository : IIssuerTrustRepository
{
private readonly ConcurrentDictionary<(string Tenant, string Issuer), IssuerTrustOverrideRecord> _store = new();
public Task<IssuerTrustOverrideRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryGetValue((tenantId, issuerId), out var record);
return Task.FromResult(record);
}
public Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken)
{
_store[(record.TenantId, record.IssuerId)] = record;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryRemove((tenantId, issuerId), out _);
return Task.CompletedTask;
}
}
private sealed class FakeIssuerAuditSink : IIssuerAuditSink
{
private readonly ConcurrentBag<IssuerAuditEntry> _entries = new();
public IReadOnlyCollection<IssuerAuditEntry> Entries => _entries.ToArray();
public Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
{
_entries.Add(entry);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,52 @@
using FluentAssertions;
using StellaOps.IssuerDirectory.Core.Domain;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
public partial class IssuerTrustServiceTests
{
[Fact]
public async Task SetAsync_SavesOverrideWithinBoundsAsync()
{
var result = await _service.SetAsync("tenant-a", "issuer-1", 4.5m, "reason", "actor", CancellationToken.None);
result.Weight.Should().Be(4.5m);
result.UpdatedBy.Should().Be("actor");
var view = await _service.GetAsync("tenant-a", "issuer-1", includeGlobal: true, CancellationToken.None);
view.EffectiveWeight.Should().Be(4.5m);
_auditSink.Entries.Should().Contain(entry => entry.Action == "trust_override_set");
}
[Fact]
public async Task SetAsync_InvalidWeight_ThrowsAsync()
{
var action = async () => await _service.SetAsync("tenant-a", "issuer-1", 20m, null, "actor", CancellationToken.None);
await action.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task GetAsync_FallsBackToGlobalAsync()
{
await _service.SetAsync(IssuerTenants.Global, "issuer-1", -2m, null, "seed", CancellationToken.None);
var view = await _service.GetAsync("tenant-b", "issuer-1", includeGlobal: true, CancellationToken.None);
view.EffectiveWeight.Should().Be(-2m);
view.GlobalOverride.Should().NotBeNull();
}
[Fact]
public async Task DeleteAsync_RemovesOverrideAsync()
{
await _service.SetAsync("tenant-a", "issuer-1", 1m, null, "actor", CancellationToken.None);
await _service.DeleteAsync("tenant-a", "issuer-1", "actor", "clearing", CancellationToken.None);
var view = await _service.GetAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
view.TenantOverride.Should().BeNull();
_auditSink.Entries.Should().Contain(entry => entry.Action == "trust_override_deleted");
}
}

View File

@@ -1,14 +1,13 @@
using System.Collections.Concurrent;
using FluentAssertions;
using System;
using Microsoft.Extensions.Time.Testing;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Services;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests.Services;
public class IssuerTrustServiceTests
public partial class IssuerTrustServiceTests
{
private readonly FakeIssuerRepository _issuerRepository = new();
private readonly FakeIssuerTrustRepository _trustRepository = new();
@@ -37,117 +36,4 @@ public class IssuerTrustServiceTests
_issuerRepository.Add(issuer);
_issuerRepository.Add(issuer with { TenantId = IssuerTenants.Global, IsSystemSeed = true });
}
[Fact]
public async Task SetAsync_SavesOverrideWithinBounds()
{
var result = await _service.SetAsync("tenant-a", "issuer-1", 4.5m, "reason", "actor", CancellationToken.None);
result.Weight.Should().Be(4.5m);
result.UpdatedBy.Should().Be("actor");
var view = await _service.GetAsync("tenant-a", "issuer-1", includeGlobal: true, CancellationToken.None);
view.EffectiveWeight.Should().Be(4.5m);
_auditSink.Entries.Should().Contain(entry => entry.Action == "trust_override_set");
}
[Fact]
public async Task SetAsync_InvalidWeight_Throws()
{
var action = async () => await _service.SetAsync("tenant-a", "issuer-1", 20m, null, "actor", CancellationToken.None);
await action.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task GetAsync_FallsBackToGlobal()
{
await _service.SetAsync(IssuerTenants.Global, "issuer-1", -2m, null, "seed", CancellationToken.None);
var view = await _service.GetAsync("tenant-b", "issuer-1", includeGlobal: true, CancellationToken.None);
view.EffectiveWeight.Should().Be(-2m);
view.GlobalOverride.Should().NotBeNull();
}
[Fact]
public async Task DeleteAsync_RemovesOverride()
{
await _service.SetAsync("tenant-a", "issuer-1", 1m, null, "actor", CancellationToken.None);
await _service.DeleteAsync("tenant-a", "issuer-1", "actor", "clearing", CancellationToken.None);
var view = await _service.GetAsync("tenant-a", "issuer-1", includeGlobal: false, CancellationToken.None);
view.TenantOverride.Should().BeNull();
_auditSink.Entries.Should().Contain(entry => entry.Action == "trust_override_deleted");
}
private sealed class FakeIssuerRepository : IIssuerRepository
{
private readonly ConcurrentDictionary<(string Tenant, string Id), IssuerRecord> _store = new();
public void Add(IssuerRecord record) => _store[(record.TenantId, record.Id)] = record;
public Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryGetValue((tenantId, issuerId), out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
{
_store[(record.TenantId, record.Id)] = record;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryRemove((tenantId, issuerId), out _);
return Task.CompletedTask;
}
}
private sealed class FakeIssuerTrustRepository : IIssuerTrustRepository
{
private readonly ConcurrentDictionary<(string Tenant, string Issuer), IssuerTrustOverrideRecord> _store = new();
public Task<IssuerTrustOverrideRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryGetValue((tenantId, issuerId), out var record);
return Task.FromResult(record);
}
public Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken)
{
_store[(record.TenantId, record.IssuerId)] = record;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
_store.TryRemove((tenantId, issuerId), out _);
return Task.CompletedTask;
}
}
private sealed class FakeIssuerAuditSink : IIssuerAuditSink
{
private readonly ConcurrentBag<IssuerAuditEntry> _entries = new();
public IReadOnlyCollection<IssuerAuditEntry> Entries => _entries.ToArray();
public Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
{
_entries.Add(entry);
return Task.CompletedTask;
}
}
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0374-T | DONE | Revalidated 2026-01-07; test coverage audit for IssuerDirectory.Core.Tests. |
| AUDIT-0374-A | DONE | Waived (test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-07 | DONE | 2026-02-04: Split client/service test fixtures and added cache coverage (SPRINT_20260130_002). |

View File

@@ -0,0 +1,37 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Validation;
using Xunit;
namespace StellaOps.IssuerDirectory.Core.Tests.Validation;
public class IssuerKeyValidatorTests
{
[Fact]
public void Validate_Ed25519RejectsInvalidBase64()
{
var material = new IssuerKeyMaterial("base64", "not-base64");
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T00:00:00Z"));
var action = () => IssuerKeyValidator.Validate(IssuerKeyType.Ed25519PublicKey, material, null, timeProvider);
action.Should().Throw<InvalidOperationException>()
.WithMessage("*base64*");
}
[Fact]
public void Validate_DsseRejectsInvalidLength()
{
var material = new IssuerKeyMaterial("base64", Convert.ToBase64String(new byte[10]));
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T00:00:00Z"));
var action = () => IssuerKeyValidator.Validate(IssuerKeyType.DssePublicKey, material, null, timeProvider);
action.Should().Throw<InvalidOperationException>()
.WithMessage("*DSSE*");
}
}

View File

@@ -0,0 +1,45 @@
using System;
namespace StellaOps.IssuerDirectory.Core.Domain;
public sealed partial record IssuerKeyRecord
{
public static IssuerKeyRecord Create(
string id,
string issuerId,
string tenantId,
IssuerKeyType type,
IssuerKeyMaterial material,
string fingerprint,
DateTimeOffset createdAtUtc,
string createdBy,
DateTimeOffset? expiresAtUtc,
string? replacesKeyId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(material);
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint);
ArgumentException.ThrowIfNullOrWhiteSpace(createdBy);
return new IssuerKeyRecord
{
Id = id.Trim(),
IssuerId = issuerId.Trim(),
TenantId = tenantId.Trim(),
Type = type,
Status = IssuerKeyStatus.Active,
Material = material,
Fingerprint = fingerprint.Trim(),
CreatedAtUtc = createdAtUtc,
CreatedBy = createdBy.Trim(),
UpdatedAtUtc = createdAtUtc,
UpdatedBy = createdBy.Trim(),
ExpiresAtUtc = expiresAtUtc?.ToUniversalTime(),
RetiredAtUtc = null,
RevokedAtUtc = null,
ReplacesKeyId = string.IsNullOrWhiteSpace(replacesKeyId) ? null : replacesKeyId.Trim()
};
}
}

View File

@@ -0,0 +1,42 @@
using System;
namespace StellaOps.IssuerDirectory.Core.Domain;
public sealed partial record IssuerKeyRecord
{
public IssuerKeyRecord WithStatus(
IssuerKeyStatus status,
DateTimeOffset timestampUtc,
string updatedBy)
{
ArgumentException.ThrowIfNullOrWhiteSpace(updatedBy);
return status switch
{
IssuerKeyStatus.Active => this with
{
Status = status,
UpdatedAtUtc = timestampUtc,
UpdatedBy = updatedBy.Trim(),
RetiredAtUtc = null,
RevokedAtUtc = null
},
IssuerKeyStatus.Retired => this with
{
Status = status,
UpdatedAtUtc = timestampUtc,
UpdatedBy = updatedBy.Trim(),
RetiredAtUtc = timestampUtc,
RevokedAtUtc = null
},
IssuerKeyStatus.Revoked => this with
{
Status = status,
UpdatedAtUtc = timestampUtc,
UpdatedBy = updatedBy.Trim(),
RevokedAtUtc = timestampUtc
},
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unsupported key status.")
};
}
}

View File

@@ -3,7 +3,7 @@ namespace StellaOps.IssuerDirectory.Core.Domain;
/// <summary>
/// Represents an issuer signing key.
/// </summary>
public sealed record IssuerKeyRecord
public sealed partial record IssuerKeyRecord
{
public required string Id { get; init; }
@@ -34,79 +34,4 @@ public sealed record IssuerKeyRecord
public DateTimeOffset? RevokedAtUtc { get; init; }
public string? ReplacesKeyId { get; init; }
public static IssuerKeyRecord Create(
string id,
string issuerId,
string tenantId,
IssuerKeyType type,
IssuerKeyMaterial material,
string fingerprint,
DateTimeOffset createdAtUtc,
string createdBy,
DateTimeOffset? expiresAtUtc,
string? replacesKeyId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(material);
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint);
ArgumentException.ThrowIfNullOrWhiteSpace(createdBy);
return new IssuerKeyRecord
{
Id = id.Trim(),
IssuerId = issuerId.Trim(),
TenantId = tenantId.Trim(),
Type = type,
Status = IssuerKeyStatus.Active,
Material = material,
Fingerprint = fingerprint.Trim(),
CreatedAtUtc = createdAtUtc,
CreatedBy = createdBy.Trim(),
UpdatedAtUtc = createdAtUtc,
UpdatedBy = createdBy.Trim(),
ExpiresAtUtc = expiresAtUtc?.ToUniversalTime(),
RetiredAtUtc = null,
RevokedAtUtc = null,
ReplacesKeyId = string.IsNullOrWhiteSpace(replacesKeyId) ? null : replacesKeyId.Trim()
};
}
public IssuerKeyRecord WithStatus(
IssuerKeyStatus status,
DateTimeOffset timestampUtc,
string updatedBy)
{
ArgumentException.ThrowIfNullOrWhiteSpace(updatedBy);
return status switch
{
IssuerKeyStatus.Active => this with
{
Status = status,
UpdatedAtUtc = timestampUtc,
UpdatedBy = updatedBy.Trim(),
RetiredAtUtc = null,
RevokedAtUtc = null
},
IssuerKeyStatus.Retired => this with
{
Status = status,
UpdatedAtUtc = timestampUtc,
UpdatedBy = updatedBy.Trim(),
RetiredAtUtc = timestampUtc,
RevokedAtUtc = null
},
IssuerKeyStatus.Revoked => this with
{
Status = status,
UpdatedAtUtc = timestampUtc,
UpdatedBy = updatedBy.Trim(),
RevokedAtUtc = timestampUtc
},
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unsupported key status.")
};
}
}

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.IssuerDirectory.Core.Domain;
public sealed partial record IssuerRecord
{
public static IssuerRecord Create(
string id,
string tenantId,
string displayName,
string slug,
string? description,
IssuerContact contact,
IssuerMetadata metadata,
IEnumerable<IssuerEndpoint>? endpoints,
IEnumerable<string>? tags,
DateTimeOffset timestampUtc,
string actor,
bool isSystemSeed)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("Identifier is required.", nameof(id));
}
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new ArgumentException("Tenant must be provided.", nameof(tenantId));
}
if (string.IsNullOrWhiteSpace(displayName))
{
throw new ArgumentException("Display name is required.", nameof(displayName));
}
if (string.IsNullOrWhiteSpace(slug))
{
throw new ArgumentException("Slug is required.", nameof(slug));
}
if (contact is null)
{
throw new ArgumentNullException(nameof(contact));
}
if (metadata is null)
{
throw new ArgumentNullException(nameof(metadata));
}
if (string.IsNullOrWhiteSpace(actor))
{
throw new ArgumentException("Actor is required.", nameof(actor));
}
var normalizedTags = NormalizeTags(tags);
return new IssuerRecord
{
Id = id.Trim(),
TenantId = tenantId.Trim(),
DisplayName = displayName.Trim(),
Slug = slug.Trim().ToLowerInvariant(),
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim(),
Contact = contact,
Metadata = metadata,
Endpoints = (endpoints ?? Array.Empty<IssuerEndpoint>()).ToArray(),
Tags = normalizedTags,
CreatedAtUtc = timestampUtc.ToUniversalTime(),
CreatedBy = actor.Trim(),
UpdatedAtUtc = timestampUtc.ToUniversalTime(),
UpdatedBy = actor.Trim(),
IsSystemSeed = isSystemSeed
};
}
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.IssuerDirectory.Core.Domain;
public sealed partial record IssuerRecord
{
private static readonly StringComparer _tagComparer = StringComparer.OrdinalIgnoreCase;
private static IReadOnlyCollection<string> NormalizeTags(IEnumerable<string>? tags)
{
return (tags ?? Array.Empty<string>())
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag.Trim().ToLowerInvariant())
.Distinct(_tagComparer)
.ToArray();
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.IssuerDirectory.Core.Domain;
public sealed partial record IssuerRecord
{
public IssuerRecord WithUpdated(
IssuerContact contact,
IssuerMetadata metadata,
IEnumerable<IssuerEndpoint>? endpoints,
IEnumerable<string>? tags,
string displayName,
string? description,
DateTimeOffset updatedAtUtc,
string updatedBy)
{
if (contact is null)
{
throw new ArgumentNullException(nameof(contact));
}
if (metadata is null)
{
throw new ArgumentNullException(nameof(metadata));
}
if (string.IsNullOrWhiteSpace(displayName))
{
throw new ArgumentException("Display name is required.", nameof(displayName));
}
if (string.IsNullOrWhiteSpace(updatedBy))
{
throw new ArgumentException("Actor is required.", nameof(updatedBy));
}
var normalizedTags = NormalizeTags(tags);
return this with
{
DisplayName = displayName.Trim(),
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim(),
Contact = contact,
Metadata = metadata,
Endpoints = (endpoints ?? Array.Empty<IssuerEndpoint>()).ToArray(),
Tags = normalizedTags,
UpdatedAtUtc = updatedAtUtc.ToUniversalTime(),
UpdatedBy = updatedBy.Trim()
};
}
}

View File

@@ -3,10 +3,8 @@ namespace StellaOps.IssuerDirectory.Core.Domain;
/// <summary>
/// Represents a VEX issuer or CSAF publisher entry managed by the Issuer Directory.
/// </summary>
public sealed record IssuerRecord
public sealed partial record IssuerRecord
{
private static readonly StringComparer TagComparer = StringComparer.OrdinalIgnoreCase;
public required string Id { get; init; }
public required string TenantId { get; init; }
@@ -34,127 +32,4 @@ public sealed record IssuerRecord
public required string UpdatedBy { get; init; }
public bool IsSystemSeed { get; init; }
public static IssuerRecord Create(
string id,
string tenantId,
string displayName,
string slug,
string? description,
IssuerContact contact,
IssuerMetadata metadata,
IEnumerable<IssuerEndpoint>? endpoints,
IEnumerable<string>? tags,
DateTimeOffset timestampUtc,
string actor,
bool isSystemSeed)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("Identifier is required.", nameof(id));
}
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new ArgumentException("Tenant must be provided.", nameof(tenantId));
}
if (string.IsNullOrWhiteSpace(displayName))
{
throw new ArgumentException("Display name is required.", nameof(displayName));
}
if (string.IsNullOrWhiteSpace(slug))
{
throw new ArgumentException("Slug is required.", nameof(slug));
}
if (contact is null)
{
throw new ArgumentNullException(nameof(contact));
}
if (metadata is null)
{
throw new ArgumentNullException(nameof(metadata));
}
if (string.IsNullOrWhiteSpace(actor))
{
throw new ArgumentException("Actor is required.", nameof(actor));
}
var normalizedTags = (tags ?? Array.Empty<string>())
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag.Trim().ToLowerInvariant())
.Distinct(TagComparer)
.ToArray();
return new IssuerRecord
{
Id = id.Trim(),
TenantId = tenantId.Trim(),
DisplayName = displayName.Trim(),
Slug = slug.Trim().ToLowerInvariant(),
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim(),
Contact = contact,
Metadata = metadata,
Endpoints = (endpoints ?? Array.Empty<IssuerEndpoint>()).ToArray(),
Tags = normalizedTags,
CreatedAtUtc = timestampUtc.ToUniversalTime(),
CreatedBy = actor.Trim(),
UpdatedAtUtc = timestampUtc.ToUniversalTime(),
UpdatedBy = actor.Trim(),
IsSystemSeed = isSystemSeed
};
}
public IssuerRecord WithUpdated(
IssuerContact contact,
IssuerMetadata metadata,
IEnumerable<IssuerEndpoint>? endpoints,
IEnumerable<string>? tags,
string displayName,
string? description,
DateTimeOffset updatedAtUtc,
string updatedBy)
{
if (contact is null)
{
throw new ArgumentNullException(nameof(contact));
}
if (metadata is null)
{
throw new ArgumentNullException(nameof(metadata));
}
if (string.IsNullOrWhiteSpace(displayName))
{
throw new ArgumentException("Display name is required.", nameof(displayName));
}
if (string.IsNullOrWhiteSpace(updatedBy))
{
throw new ArgumentException("Actor is required.", nameof(updatedBy));
}
var normalizedTags = (tags ?? Array.Empty<string>())
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag.Trim().ToLowerInvariant())
.Distinct(TagComparer)
.ToArray();
return this with
{
DisplayName = displayName.Trim(),
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim(),
Contact = contact,
Metadata = metadata,
Endpoints = (endpoints ?? Array.Empty<IssuerEndpoint>()).ToArray(),
Tags = normalizedTags,
UpdatedAtUtc = updatedAtUtc.ToUniversalTime(),
UpdatedBy = updatedBy.Trim()
};
}
}

View File

@@ -5,23 +5,23 @@ namespace StellaOps.IssuerDirectory.Core.Observability;
internal static class IssuerDirectoryMetrics
{
private static readonly Meter Meter = new("StellaOps.IssuerDirectory", "1.0");
private static readonly Meter _meter = new("StellaOps.IssuerDirectory", "1.0");
private static readonly Counter<long> IssuerChangeCounter = Meter.CreateCounter<long>(
private static readonly Counter<long> _issuerChangeCounter = _meter.CreateCounter<long>(
"issuer_directory_changes_total",
description: "Counts issuer create/update/delete events.");
private static readonly Counter<long> KeyOperationCounter = Meter.CreateCounter<long>(
private static readonly Counter<long> _keyOperationCounter = _meter.CreateCounter<long>(
"issuer_directory_key_operations_total",
description: "Counts issuer key create/rotate/revoke operations.");
private static readonly Counter<long> KeyValidationFailureCounter = Meter.CreateCounter<long>(
private static readonly Counter<long> _keyValidationFailureCounter = _meter.CreateCounter<long>(
"issuer_directory_key_validation_failures_total",
description: "Counts issuer key validation or verification failures.");
public static void RecordIssuerChange(string tenantId, string issuerId, string action)
{
IssuerChangeCounter.Add(
_issuerChangeCounter.Add(
1,
new[]
{
@@ -33,7 +33,7 @@ internal static class IssuerDirectoryMetrics
public static void RecordKeyOperation(string tenantId, string issuerId, string operation, string keyType)
{
KeyOperationCounter.Add(
_keyOperationCounter.Add(
1,
new[]
{
@@ -46,7 +46,7 @@ internal static class IssuerDirectoryMetrics
public static void RecordKeyValidationFailure(string tenantId, string issuerId, string reason)
{
KeyValidationFailureCounter.Add(
_keyValidationFailureCounter.Add(
1,
new[]
{

View File

@@ -0,0 +1,30 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerDirectoryService
{
private async Task WriteAuditAsync(
IssuerRecord record,
string action,
string actor,
string? reason,
CancellationToken cancellationToken)
{
var audit = new IssuerAuditEntry(
record.TenantId,
record.Id,
action,
_timeProvider.GetUtcNow(),
actor,
reason,
metadata: new Dictionary<string, string>
{
["display_name"] = record.DisplayName,
["slug"] = record.Slug,
["is_system_seed"] = record.IsSystemSeed.ToString()
});
await _auditSink.WriteAsync(audit, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.Extensions.Logging;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Observability;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerDirectoryService
{
public async Task<IssuerRecord> CreateAsync(
string tenantId,
string issuerId,
string displayName,
string slug,
string? description,
IssuerContact contact,
IssuerMetadata metadata,
IEnumerable<IssuerEndpoint>? endpoints,
IEnumerable<string>? tags,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var timestamp = _timeProvider.GetUtcNow();
var record = IssuerRecord.Create(
issuerId,
tenantId,
displayName,
slug,
description,
contact,
metadata,
endpoints,
tags,
timestamp,
actor,
isSystemSeed: false);
await _repository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(record, "created", actor, reason, cancellationToken).ConfigureAwait(false);
IssuerDirectoryMetrics.RecordIssuerChange(tenantId, issuerId, "created");
_logger.LogInformation(
"Issuer {IssuerId} created for tenant {TenantId} by {Actor}.",
issuerId,
tenantId,
actor);
return record;
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.Extensions.Logging;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Observability;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerDirectoryService
{
public async Task DeleteAsync(
string tenantId,
string issuerId,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
await _repository.DeleteAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
var timestamp = _timeProvider.GetUtcNow();
var audit = new IssuerAuditEntry(
tenantId,
issuerId,
action: "deleted",
timestampUtc: timestamp,
actor: actor,
reason: reason,
metadata: null);
await _auditSink.WriteAsync(audit, cancellationToken).ConfigureAwait(false);
IssuerDirectoryMetrics.RecordIssuerChange(tenantId, issuerId, "deleted");
_logger.LogInformation(
"Issuer {IssuerId} deleted for tenant {TenantId} by {Actor}.",
issuerId,
tenantId,
actor);
}
}

View File

@@ -0,0 +1,44 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerDirectoryService
{
public async Task<IReadOnlyCollection<IssuerRecord>> ListAsync(
string tenantId,
bool includeGlobal,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var tenantIssuers = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (!includeGlobal)
{
return tenantIssuers.OrderBy(record => record.Slug, StringComparer.Ordinal).ToArray();
}
var globalIssuers = await _repository.ListGlobalAsync(cancellationToken).ConfigureAwait(false);
return tenantIssuers.Concat(globalIssuers)
.DistinctBy(record => (record.TenantId, record.Id))
.OrderBy(record => record.Slug, StringComparer.Ordinal)
.ToArray();
}
public async Task<IssuerRecord?> GetAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var issuer = await _repository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
if (issuer is not null || !includeGlobal)
{
return issuer;
}
return await _repository.GetAsync(IssuerTenants.Global, issuerId, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,49 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerDirectoryService
{
public async Task SeedAsync(IEnumerable<IssuerRecord> seeds, CancellationToken cancellationToken)
{
if (seeds is null)
{
throw new ArgumentNullException(nameof(seeds));
}
foreach (var seed in seeds)
{
if (!seed.IsSystemSeed)
{
continue;
}
var existing = await _repository.GetAsync(seed.TenantId, seed.Id, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await _repository.UpsertAsync(seed, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(seed, "seeded", seed.UpdatedBy, "CSAF bootstrap import", cancellationToken)
.ConfigureAwait(false);
}
else
{
var refreshed = existing.WithUpdated(
seed.Contact,
seed.Metadata,
seed.Endpoints,
seed.Tags,
seed.DisplayName,
seed.Description,
_timeProvider.GetUtcNow(),
seed.UpdatedBy)
with
{
IsSystemSeed = true
};
await _repository.UpsertAsync(refreshed, cancellationToken).ConfigureAwait(false);
}
}
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.Extensions.Logging;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Observability;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerDirectoryService
{
public async Task<IssuerRecord> UpdateAsync(
string tenantId,
string issuerId,
string displayName,
string? description,
IssuerContact contact,
IssuerMetadata metadata,
IEnumerable<IssuerEndpoint>? endpoints,
IEnumerable<string>? tags,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var existing = await _repository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
throw new InvalidOperationException("Issuer does not exist.");
}
var timestamp = _timeProvider.GetUtcNow();
var updated = existing.WithUpdated(
contact,
metadata,
endpoints,
tags,
displayName,
description,
timestamp,
actor);
await _repository.UpsertAsync(updated, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(updated, "updated", actor, reason, cancellationToken).ConfigureAwait(false);
IssuerDirectoryMetrics.RecordIssuerChange(tenantId, issuerId, "updated");
_logger.LogInformation(
"Issuer {IssuerId} updated for tenant {TenantId} by {Actor}.",
issuerId,
tenantId,
actor);
return updated;
}
}

View File

@@ -1,14 +1,14 @@
using Microsoft.Extensions.Logging;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Observability;
namespace StellaOps.IssuerDirectory.Core.Services;
/// <summary>
/// Coordinates issuer directory operations with persistence, validation, and auditing.
/// </summary>
public sealed class IssuerDirectoryService
public sealed partial class IssuerDirectoryService
{
private readonly IIssuerRepository _repository;
private readonly IIssuerAuditSink _auditSink;
@@ -26,227 +26,4 @@ public sealed class IssuerDirectoryService
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyCollection<IssuerRecord>> ListAsync(
string tenantId,
bool includeGlobal,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var tenantIssuers = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (!includeGlobal)
{
return tenantIssuers.OrderBy(record => record.Slug, StringComparer.Ordinal).ToArray();
}
var globalIssuers = await _repository.ListGlobalAsync(cancellationToken).ConfigureAwait(false);
return tenantIssuers.Concat(globalIssuers)
.DistinctBy(record => (record.TenantId, record.Id))
.OrderBy(record => record.Slug, StringComparer.Ordinal)
.ToArray();
}
public async Task<IssuerRecord?> GetAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var issuer = await _repository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
if (issuer is not null || !includeGlobal)
{
return issuer;
}
return await _repository.GetAsync(IssuerTenants.Global, issuerId, cancellationToken).ConfigureAwait(false);
}
public async Task<IssuerRecord> CreateAsync(
string tenantId,
string issuerId,
string displayName,
string slug,
string? description,
IssuerContact contact,
IssuerMetadata metadata,
IEnumerable<IssuerEndpoint>? endpoints,
IEnumerable<string>? tags,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var timestamp = _timeProvider.GetUtcNow();
var record = IssuerRecord.Create(
issuerId,
tenantId,
displayName,
slug,
description,
contact,
metadata,
endpoints,
tags,
timestamp,
actor,
isSystemSeed: false);
await _repository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(record, "created", actor, reason, cancellationToken).ConfigureAwait(false);
IssuerDirectoryMetrics.RecordIssuerChange(tenantId, issuerId, "created");
_logger.LogInformation(
"Issuer {IssuerId} created for tenant {TenantId} by {Actor}.",
issuerId,
tenantId,
actor);
return record;
}
public async Task<IssuerRecord> UpdateAsync(
string tenantId,
string issuerId,
string displayName,
string? description,
IssuerContact contact,
IssuerMetadata metadata,
IEnumerable<IssuerEndpoint>? endpoints,
IEnumerable<string>? tags,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var existing = await _repository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException($"Issuer '{issuerId}' not found for tenant '{tenantId}'.");
var timestamp = _timeProvider.GetUtcNow();
var updated = existing.WithUpdated(
contact,
metadata,
endpoints,
tags,
displayName,
description,
timestamp,
actor);
await _repository.UpsertAsync(updated, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(updated, "updated", actor, reason, cancellationToken).ConfigureAwait(false);
IssuerDirectoryMetrics.RecordIssuerChange(tenantId, issuerId, "updated");
_logger.LogInformation(
"Issuer {IssuerId} updated for tenant {TenantId} by {Actor}.",
issuerId,
tenantId,
actor);
return updated;
}
public async Task DeleteAsync(
string tenantId,
string issuerId,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
await _repository.DeleteAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
var timestamp = _timeProvider.GetUtcNow();
var audit = new IssuerAuditEntry(
tenantId,
issuerId,
action: "deleted",
timestampUtc: timestamp,
actor: actor,
reason: reason,
metadata: null);
await _auditSink.WriteAsync(audit, cancellationToken).ConfigureAwait(false);
IssuerDirectoryMetrics.RecordIssuerChange(tenantId, issuerId, "deleted");
_logger.LogInformation(
"Issuer {IssuerId} deleted for tenant {TenantId} by {Actor}.",
issuerId,
tenantId,
actor);
}
public async Task SeedAsync(IEnumerable<IssuerRecord> seeds, CancellationToken cancellationToken)
{
if (seeds is null)
{
throw new ArgumentNullException(nameof(seeds));
}
foreach (var seed in seeds)
{
if (!seed.IsSystemSeed)
{
continue;
}
var existing = await _repository.GetAsync(seed.TenantId, seed.Id, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await _repository.UpsertAsync(seed, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(seed, "seeded", seed.UpdatedBy, "CSAF bootstrap import", cancellationToken)
.ConfigureAwait(false);
}
else
{
var refreshed = existing.WithUpdated(
seed.Contact,
seed.Metadata,
seed.Endpoints,
seed.Tags,
seed.DisplayName,
seed.Description,
_timeProvider.GetUtcNow(),
seed.UpdatedBy)
with
{
IsSystemSeed = true
};
await _repository.UpsertAsync(refreshed, cancellationToken).ConfigureAwait(false);
}
}
}
private async Task WriteAuditAsync(
IssuerRecord record,
string action,
string actor,
string? reason,
CancellationToken cancellationToken)
{
var audit = new IssuerAuditEntry(
record.TenantId,
record.Id,
action,
_timeProvider.GetUtcNow(),
actor,
reason,
metadata: new Dictionary<string, string>
{
["display_name"] = record.DisplayName,
["slug"] = record.Slug,
["is_system_seed"] = record.IsSystemSeed.ToString()
});
await _auditSink.WriteAsync(audit, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,83 @@
using Microsoft.Extensions.Logging;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Observability;
using StellaOps.IssuerDirectory.Core.Validation;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerKeyService
{
public async Task<IssuerKeyRecord> AddAsync(
string tenantId,
string issuerId,
IssuerKeyType type,
IssuerKeyMaterial material,
DateTimeOffset? expiresAtUtc,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
await EnsureIssuerExistsAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
IssuerKeyValidationResult validation;
try
{
validation = IssuerKeyValidator.Validate(type, material, expiresAtUtc, _timeProvider);
}
catch (Exception ex)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, ex.GetType().Name);
_logger.LogWarning(
ex,
"Key validation failed for issuer {IssuerId} (tenant={TenantId}) during add.",
issuerId,
tenantId);
throw;
}
var fingerprint = ComputeFingerprint(validation.RawKeyBytes);
var existing = await _keyRepository.GetByFingerprintAsync(tenantId, issuerId, fingerprint, cancellationToken)
.ConfigureAwait(false);
if (existing is not null && existing.Status == IssuerKeyStatus.Active)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "duplicate_fingerprint");
_logger.LogWarning(
"Duplicate active key detected for issuer {IssuerId} (tenant={TenantId}).",
issuerId,
tenantId);
throw new InvalidOperationException("An identical active key already exists for this issuer.");
}
var now = _timeProvider.GetUtcNow();
var record = IssuerKeyRecord.Create(
_guidProvider.NewGuid().ToString("n"),
issuerId,
tenantId,
type,
validation.Material,
fingerprint,
now,
actor,
validation.ExpiresAtUtc,
replacesKeyId: null);
await _keyRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(record, "key_created", actor, reason, cancellationToken).ConfigureAwait(false);
IssuerDirectoryMetrics.RecordKeyOperation(tenantId, issuerId, "created", type.ToString());
_logger.LogInformation(
"Issuer key {KeyId} created for issuer {IssuerId} (tenant={TenantId}) by {Actor}.",
record.Id,
issuerId,
tenantId,
actor);
return record;
}
}

View File

@@ -0,0 +1,52 @@
using System.Security.Cryptography;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerKeyService
{
private async Task EnsureIssuerExistsAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
var issuer = await _issuerRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
if (issuer is null)
{
var global = await _issuerRepository.GetAsync(IssuerTenants.Global, issuerId, cancellationToken).ConfigureAwait(false);
if (global is null)
{
throw new InvalidOperationException("Issuer does not exist.");
}
}
}
private async Task WriteAuditAsync(
IssuerKeyRecord record,
string action,
string actor,
string? reason,
CancellationToken cancellationToken)
{
var audit = new IssuerAuditEntry(
record.TenantId,
record.IssuerId,
action,
_timeProvider.GetUtcNow(),
actor,
reason,
new Dictionary<string, string>
{
["key_id"] = record.Id,
["key_type"] = record.Type.ToString(),
["fingerprint"] = record.Fingerprint,
["status"] = record.Status.ToString()
});
await _auditSink.WriteAsync(audit, cancellationToken).ConfigureAwait(false);
}
private static string ComputeFingerprint(byte[] rawKeyBytes)
{
var hash = SHA256.HashData(rawKeyBytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,28 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerKeyService
{
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var tenantKeys = await _keyRepository.ListAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
if (!includeGlobal)
{
return tenantKeys.OrderBy(key => key.CreatedAtUtc).ToArray();
}
var globalKeys = await _keyRepository.ListGlobalAsync(issuerId, cancellationToken).ConfigureAwait(false);
return tenantKeys.Concat(globalKeys)
.DistinctBy(key => (key.TenantId, key.Id))
.OrderBy(key => key.CreatedAtUtc)
.ToArray();
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.Extensions.Logging;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Observability;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerKeyService
{
public async Task RevokeAsync(
string tenantId,
string issuerId,
string keyId,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
var existing = await _keyRepository.GetAsync(tenantId, issuerId, keyId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "key_not_found");
_logger.LogWarning(
"Attempted to revoke missing key {KeyId} for issuer {IssuerId} (tenant={TenantId}).",
keyId,
issuerId,
tenantId);
throw new InvalidOperationException("Key not found for revocation.");
}
if (existing.Status == IssuerKeyStatus.Revoked)
{
return;
}
var now = _timeProvider.GetUtcNow();
var revoked = existing.WithStatus(IssuerKeyStatus.Revoked, now, actor);
await _keyRepository.UpsertAsync(revoked, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(revoked, "key_revoked", actor, reason, cancellationToken).ConfigureAwait(false);
IssuerDirectoryMetrics.RecordKeyOperation(tenantId, issuerId, "revoked", existing.Type.ToString());
_logger.LogInformation(
"Issuer key {KeyId} revoked for issuer {IssuerId} (tenant={TenantId}) by {Actor}.",
keyId,
issuerId,
tenantId,
actor);
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.Extensions.Logging;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Observability;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerKeyService
{
private async Task<IssuerKeyRecord> GetActiveKeyForRotationAsync(
string tenantId,
string issuerId,
string keyId,
CancellationToken cancellationToken)
{
var existing = await _keyRepository.GetAsync(tenantId, issuerId, keyId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "key_not_found");
_logger.LogWarning(
"Attempted to rotate missing key {KeyId} for issuer {IssuerId} (tenant={TenantId}).",
keyId,
issuerId,
tenantId);
throw new InvalidOperationException("Key not found for rotation.");
}
if (existing.Status != IssuerKeyStatus.Active)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "key_not_active");
_logger.LogWarning(
"Attempted to rotate non-active key {KeyId} (status={Status}) for issuer {IssuerId} (tenant={TenantId}).",
keyId,
existing.Status,
issuerId,
tenantId);
throw new InvalidOperationException("Only active keys can be rotated.");
}
return existing;
}
}

View File

@@ -0,0 +1,94 @@
using Microsoft.Extensions.Logging;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Observability;
using StellaOps.IssuerDirectory.Core.Validation;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerKeyService
{
public async Task<IssuerKeyRecord> RotateAsync(
string tenantId,
string issuerId,
string keyId,
IssuerKeyType newType,
IssuerKeyMaterial newMaterial,
DateTimeOffset? expiresAtUtc,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
var existing = await GetActiveKeyForRotationAsync(tenantId, issuerId, keyId, cancellationToken).ConfigureAwait(false);
await EnsureIssuerExistsAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
IssuerKeyValidationResult validation;
try
{
validation = IssuerKeyValidator.Validate(newType, newMaterial, expiresAtUtc, _timeProvider);
}
catch (Exception ex)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, ex.GetType().Name);
_logger.LogWarning(
ex,
"Key validation failed for issuer {IssuerId} (tenant={TenantId}) during rotation.",
issuerId,
tenantId);
throw;
}
var fingerprint = ComputeFingerprint(validation.RawKeyBytes);
var duplicate = await _keyRepository.GetByFingerprintAsync(tenantId, issuerId, fingerprint, cancellationToken)
.ConfigureAwait(false);
if (duplicate is not null && duplicate.Status == IssuerKeyStatus.Active)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "duplicate_fingerprint");
_logger.LogWarning(
"Duplicate active key detected during rotation for issuer {IssuerId} (tenant={TenantId}).",
issuerId,
tenantId);
throw new InvalidOperationException("An identical active key already exists for this issuer.");
}
var now = _timeProvider.GetUtcNow();
var retired = existing.WithStatus(IssuerKeyStatus.Retired, now, actor);
await _keyRepository.UpsertAsync(retired, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(retired, "key_retired", actor, reason ?? "rotation", cancellationToken)
.ConfigureAwait(false);
var replacement = IssuerKeyRecord.Create(
_guidProvider.NewGuid().ToString("n"),
issuerId,
tenantId,
newType,
validation.Material,
fingerprint,
now,
actor,
validation.ExpiresAtUtc,
replacesKeyId: existing.Id);
await _keyRepository.UpsertAsync(replacement, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(replacement, "key_rotated", actor, reason, cancellationToken).ConfigureAwait(false);
IssuerDirectoryMetrics.RecordKeyOperation(tenantId, issuerId, "rotated", newType.ToString());
_logger.LogInformation(
"Issuer key {OldKeyId} rotated for issuer {IssuerId} (tenant={TenantId}) by {Actor}; new key {NewKeyId}.",
existing.Id,
issuerId,
tenantId,
actor,
replacement.Id);
return replacement;
}
}

View File

@@ -1,18 +1,15 @@
using Microsoft.Extensions.Logging;
using StellaOps.Determinism;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Core.Observability;
using StellaOps.IssuerDirectory.Core.Validation;
using System.Security.Cryptography;
namespace StellaOps.IssuerDirectory.Core.Services;
/// <summary>
/// Manages issuer signing keys.
/// </summary>
public sealed class IssuerKeyService
public sealed partial class IssuerKeyService
{
private readonly IIssuerRepository _issuerRepository;
private readonly IIssuerKeyRepository _keyRepository;
@@ -36,292 +33,4 @@ public sealed class IssuerKeyService
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
}
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var tenantKeys = await _keyRepository.ListAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
if (!includeGlobal)
{
return tenantKeys.OrderBy(key => key.CreatedAtUtc).ToArray();
}
var globalKeys = await _keyRepository.ListGlobalAsync(issuerId, cancellationToken).ConfigureAwait(false);
return tenantKeys.Concat(globalKeys)
.DistinctBy(key => (key.TenantId, key.Id))
.OrderBy(key => key.CreatedAtUtc)
.ToArray();
}
public async Task<IssuerKeyRecord> AddAsync(
string tenantId,
string issuerId,
IssuerKeyType type,
IssuerKeyMaterial material,
DateTimeOffset? expiresAtUtc,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
await EnsureIssuerExistsAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
IssuerKeyValidationResult validation;
try
{
validation = IssuerKeyValidator.Validate(type, material, expiresAtUtc, _timeProvider);
}
catch (Exception ex)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, ex.GetType().Name);
_logger.LogWarning(
ex,
"Key validation failed for issuer {IssuerId} (tenant={TenantId}) during add.",
issuerId,
tenantId);
throw;
}
var fingerprint = ComputeFingerprint(validation.RawKeyBytes);
var existing = await _keyRepository.GetByFingerprintAsync(tenantId, issuerId, fingerprint, cancellationToken)
.ConfigureAwait(false);
if (existing is not null && existing.Status == IssuerKeyStatus.Active)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "duplicate_fingerprint");
_logger.LogWarning(
"Duplicate active key detected for issuer {IssuerId} (tenant={TenantId}).",
issuerId,
tenantId);
throw new InvalidOperationException("An identical active key already exists for this issuer.");
}
var now = _timeProvider.GetUtcNow();
var record = IssuerKeyRecord.Create(
_guidProvider.NewGuid().ToString("n"),
issuerId,
tenantId,
type,
validation.Material,
fingerprint,
now,
actor,
validation.ExpiresAtUtc,
replacesKeyId: null);
await _keyRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(record, "key_created", actor, reason, cancellationToken).ConfigureAwait(false);
IssuerDirectoryMetrics.RecordKeyOperation(tenantId, issuerId, "created", type.ToString());
_logger.LogInformation(
"Issuer key {KeyId} created for issuer {IssuerId} (tenant={TenantId}) by {Actor}.",
record.Id,
issuerId,
tenantId,
actor);
return record;
}
public async Task<IssuerKeyRecord> RotateAsync(
string tenantId,
string issuerId,
string keyId,
IssuerKeyType newType,
IssuerKeyMaterial newMaterial,
DateTimeOffset? expiresAtUtc,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
var existing = await _keyRepository.GetAsync(tenantId, issuerId, keyId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "key_not_found");
_logger.LogWarning(
"Attempted to rotate missing key {KeyId} for issuer {IssuerId} (tenant={TenantId}).",
keyId,
issuerId,
tenantId);
throw new InvalidOperationException("Key not found for rotation.");
}
if (existing.Status != IssuerKeyStatus.Active)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "key_not_active");
_logger.LogWarning(
"Attempted to rotate non-active key {KeyId} (status={Status}) for issuer {IssuerId} (tenant={TenantId}).",
keyId,
existing.Status,
issuerId,
tenantId);
throw new InvalidOperationException("Only active keys can be rotated.");
}
await EnsureIssuerExistsAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
IssuerKeyValidationResult validation;
try
{
validation = IssuerKeyValidator.Validate(newType, newMaterial, expiresAtUtc, _timeProvider);
}
catch (Exception ex)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, ex.GetType().Name);
_logger.LogWarning(
ex,
"Key validation failed for issuer {IssuerId} (tenant={TenantId}) during rotation.",
issuerId,
tenantId);
throw;
}
var fingerprint = ComputeFingerprint(validation.RawKeyBytes);
var duplicate = await _keyRepository.GetByFingerprintAsync(tenantId, issuerId, fingerprint, cancellationToken)
.ConfigureAwait(false);
if (duplicate is not null && duplicate.Status == IssuerKeyStatus.Active)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "duplicate_fingerprint");
_logger.LogWarning(
"Duplicate active key detected during rotation for issuer {IssuerId} (tenant={TenantId}).",
issuerId,
tenantId);
throw new InvalidOperationException("An identical active key already exists for this issuer.");
}
var now = _timeProvider.GetUtcNow();
var retired = existing.WithStatus(IssuerKeyStatus.Retired, now, actor);
await _keyRepository.UpsertAsync(retired, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(retired, "key_retired", actor, reason ?? "rotation", cancellationToken)
.ConfigureAwait(false);
var replacement = IssuerKeyRecord.Create(
_guidProvider.NewGuid().ToString("n"),
issuerId,
tenantId,
newType,
validation.Material,
fingerprint,
now,
actor,
validation.ExpiresAtUtc,
replacesKeyId: existing.Id);
await _keyRepository.UpsertAsync(replacement, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(replacement, "key_rotated", actor, reason, cancellationToken).ConfigureAwait(false);
IssuerDirectoryMetrics.RecordKeyOperation(tenantId, issuerId, "rotated", newType.ToString());
_logger.LogInformation(
"Issuer key {OldKeyId} rotated for issuer {IssuerId} (tenant={TenantId}) by {Actor}; new key {NewKeyId}.",
existing.Id,
issuerId,
tenantId,
actor,
replacement.Id);
return replacement;
}
public async Task RevokeAsync(
string tenantId,
string issuerId,
string keyId,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
var existing = await _keyRepository.GetAsync(tenantId, issuerId, keyId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
IssuerDirectoryMetrics.RecordKeyValidationFailure(tenantId, issuerId, "key_not_found");
_logger.LogWarning(
"Attempted to revoke missing key {KeyId} for issuer {IssuerId} (tenant={TenantId}).",
keyId,
issuerId,
tenantId);
throw new InvalidOperationException("Key not found for revocation.");
}
if (existing.Status == IssuerKeyStatus.Revoked)
{
return;
}
var now = _timeProvider.GetUtcNow();
var revoked = existing.WithStatus(IssuerKeyStatus.Revoked, now, actor);
await _keyRepository.UpsertAsync(revoked, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(revoked, "key_revoked", actor, reason, cancellationToken).ConfigureAwait(false);
IssuerDirectoryMetrics.RecordKeyOperation(tenantId, issuerId, "revoked", existing.Type.ToString());
_logger.LogInformation(
"Issuer key {KeyId} revoked for issuer {IssuerId} (tenant={TenantId}) by {Actor}.",
keyId,
issuerId,
tenantId,
actor);
}
private async Task EnsureIssuerExistsAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
var issuer = await _issuerRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
if (issuer is null)
{
var global = await _issuerRepository.GetAsync(IssuerTenants.Global, issuerId, cancellationToken).ConfigureAwait(false);
if (global is null)
{
throw new InvalidOperationException("Issuer does not exist.");
}
}
}
private async Task WriteAuditAsync(
IssuerKeyRecord record,
string action,
string actor,
string? reason,
CancellationToken cancellationToken)
{
var audit = new IssuerAuditEntry(
record.TenantId,
record.IssuerId,
action,
_timeProvider.GetUtcNow(),
actor,
reason,
new Dictionary<string, string>
{
["key_id"] = record.Id,
["key_type"] = record.Type.ToString(),
["fingerprint"] = record.Fingerprint,
["status"] = record.Status.ToString()
});
await _auditSink.WriteAsync(audit, cancellationToken).ConfigureAwait(false);
}
private static string ComputeFingerprint(byte[] rawKeyBytes)
{
var hash = SHA256.HashData(rawKeyBytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,27 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerTrustService
{
public async Task DeleteAsync(
string tenantId,
string issuerId,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
var existing = await _trustRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return;
}
await _trustRepository.DeleteAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(existing, "trust_override_deleted", actor, reason, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,31 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerTrustService
{
public async Task<IssuerTrustView> GetAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var tenantOverride = await _trustRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
IssuerTrustOverrideRecord? globalOverride = null;
if (includeGlobal && !string.Equals(tenantId, IssuerTenants.Global, StringComparison.Ordinal))
{
globalOverride = await _trustRepository.GetAsync(IssuerTenants.Global, issuerId, cancellationToken)
.ConfigureAwait(false);
}
var effectiveWeight = tenantOverride?.Weight
?? globalOverride?.Weight
?? 0m;
return new IssuerTrustView(tenantOverride, globalOverride, effectiveWeight);
}
}

View File

@@ -0,0 +1,39 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerTrustService
{
private async Task EnsureIssuerExistsAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
var issuer = await _issuerRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false)
?? await _issuerRepository.GetAsync(IssuerTenants.Global, issuerId, cancellationToken).ConfigureAwait(false);
if (issuer is null)
{
throw new InvalidOperationException("Issuer does not exist.");
}
}
private async Task WriteAuditAsync(
IssuerTrustOverrideRecord record,
string action,
string actor,
string? reason,
CancellationToken cancellationToken)
{
var audit = new IssuerAuditEntry(
record.TenantId,
record.IssuerId,
action,
_timeProvider.GetUtcNow(),
actor,
reason,
new Dictionary<string, string>
{
["weight"] = record.Weight.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)
});
await _auditSink.WriteAsync(audit, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,33 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed partial class IssuerTrustService
{
public async Task<IssuerTrustOverrideRecord> SetAsync(
string tenantId,
string issuerId,
decimal weight,
string? reason,
string actor,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
await EnsureIssuerExistsAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
var existing = await _trustRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
var timestamp = _timeProvider.GetUtcNow();
IssuerTrustOverrideRecord record = existing is null
? IssuerTrustOverrideRecord.Create(issuerId, tenantId, weight, reason, timestamp, actor)
: existing.WithUpdated(weight, reason, timestamp, actor);
await _trustRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(record, "trust_override_set", actor, reason, cancellationToken).ConfigureAwait(false);
return record;
}
}

View File

@@ -1,12 +1,11 @@
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Services;
/// <summary>
/// Handles issuer trust weight overrides.
/// </summary>
public sealed class IssuerTrustService
public sealed partial class IssuerTrustService
{
private readonly IIssuerRepository _issuerRepository;
private readonly IIssuerTrustRepository _trustRepository;
@@ -24,114 +23,4 @@ public sealed class IssuerTrustService
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<IssuerTrustView> GetAsync(
string tenantId,
string issuerId,
bool includeGlobal,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var tenantOverride = await _trustRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
IssuerTrustOverrideRecord? globalOverride = null;
if (includeGlobal && !string.Equals(tenantId, IssuerTenants.Global, StringComparison.Ordinal))
{
globalOverride = await _trustRepository.GetAsync(IssuerTenants.Global, issuerId, cancellationToken).ConfigureAwait(false);
}
var effectiveWeight = tenantOverride?.Weight
?? globalOverride?.Weight
?? 0m;
return new IssuerTrustView(tenantOverride, globalOverride, effectiveWeight);
}
public async Task<IssuerTrustOverrideRecord> SetAsync(
string tenantId,
string issuerId,
decimal weight,
string? reason,
string actor,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
await EnsureIssuerExistsAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
var existing = await _trustRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
var timestamp = _timeProvider.GetUtcNow();
IssuerTrustOverrideRecord record = existing is null
? IssuerTrustOverrideRecord.Create(issuerId, tenantId, weight, reason, timestamp, actor)
: existing.WithUpdated(weight, reason, timestamp, actor);
await _trustRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(record, "trust_override_set", actor, reason, cancellationToken).ConfigureAwait(false);
return record;
}
public async Task DeleteAsync(
string tenantId,
string issuerId,
string actor,
string? reason,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
var existing = await _trustRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return;
}
await _trustRepository.DeleteAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(existing, "trust_override_deleted", actor, reason, cancellationToken).ConfigureAwait(false);
}
private async Task EnsureIssuerExistsAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
var issuer = await _issuerRepository.GetAsync(tenantId, issuerId, cancellationToken).ConfigureAwait(false)
?? await _issuerRepository.GetAsync(IssuerTenants.Global, issuerId, cancellationToken).ConfigureAwait(false);
if (issuer is null)
{
throw new InvalidOperationException("Issuer does not exist.");
}
}
private async Task WriteAuditAsync(
IssuerTrustOverrideRecord record,
string action,
string actor,
string? reason,
CancellationToken cancellationToken)
{
var audit = new IssuerAuditEntry(
record.TenantId,
record.IssuerId,
action,
_timeProvider.GetUtcNow(),
actor,
reason,
new Dictionary<string, string>
{
["weight"] = record.Weight.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)
});
await _auditSink.WriteAsync(audit, cancellationToken).ConfigureAwait(false);
}
}
public sealed record IssuerTrustView(
IssuerTrustOverrideRecord? TenantOverride,
IssuerTrustOverrideRecord? GlobalOverride,
decimal EffectiveWeight);

View File

@@ -0,0 +1,8 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Services;
public sealed record IssuerTrustView(
IssuerTrustOverrideRecord? TenantOverride,
IssuerTrustOverrideRecord? GlobalOverride,
decimal EffectiveWeight);

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0373-T | DONE | Revalidated 2026-01-07; test coverage audit for IssuerDirectory.Core. |
| AUDIT-0373-A | TODO | Pending approval (revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-07 | DONE | 2026-02-04: Split domain/services/validation into partials, normalized metrics fields, added domain/validator tests (SPRINT_20260130_002). |

View File

@@ -0,0 +1,35 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Validation;
public static partial class IssuerKeyValidator
{
private static byte[] ValidateCertificate(IssuerKeyMaterial material)
{
if (!string.Equals(material.Format, "pem", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(material.Format, "base64", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("X.509 certificates must be provided as PEM or base64.");
}
try
{
if (string.Equals(material.Format, "pem", StringComparison.OrdinalIgnoreCase))
{
using var pemCertificate = X509Certificate2.CreateFromPem(material.Value);
return pemCertificate.RawData;
}
var raw = Convert.FromBase64String(material.Value);
using var loadedCertificate = X509CertificateLoader.LoadCertificate(raw);
return loadedCertificate.RawData;
}
catch (Exception ex) when (ex is CryptographicException || ex is FormatException)
{
throw new InvalidOperationException("Certificate material is invalid or unsupported.", ex);
}
}
}

View File

@@ -0,0 +1,31 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Validation;
public static partial class IssuerKeyValidator
{
private static byte[] ValidateDsseKey(IssuerKeyMaterial material)
{
if (!string.Equals(material.Format, "base64", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("DSSE keys must use base64 format.");
}
byte[] rawBytes;
try
{
rawBytes = Convert.FromBase64String(material.Value);
}
catch (FormatException ex)
{
throw new InvalidOperationException("DSSE key material must be valid base64.", ex);
}
if (rawBytes.Length is not (32 or 48 or 64))
{
throw new InvalidOperationException("DSSE keys must contain 32, 48, or 64 bytes of public key material.");
}
return rawBytes;
}
}

View File

@@ -0,0 +1,31 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Core.Validation;
public static partial class IssuerKeyValidator
{
private static byte[] ValidateEd25519(IssuerKeyMaterial material)
{
if (!string.Equals(material.Format, "base64", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Ed25519 keys must use base64 format.");
}
byte[] rawBytes;
try
{
rawBytes = Convert.FromBase64String(material.Value);
}
catch (FormatException ex)
{
throw new InvalidOperationException("Ed25519 key material must be valid base64.", ex);
}
if (rawBytes.Length != 32)
{
throw new InvalidOperationException("Ed25519 public keys must contain 32 bytes.");
}
return rawBytes;
}
}

View File

@@ -1,14 +1,11 @@
using StellaOps.IssuerDirectory.Core.Domain;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.IssuerDirectory.Core.Validation;
/// <summary>
/// Performs validation and normalization of issuer key material.
/// </summary>
public static class IssuerKeyValidator
public static partial class IssuerKeyValidator
{
public static IssuerKeyValidationResult Validate(
IssuerKeyType type,
@@ -48,80 +45,4 @@ public static class IssuerKeyValidator
{
return new IssuerKeyMaterial(material.Format.ToLowerInvariant(), material.Value.Trim());
}
private static byte[] ValidateEd25519(IssuerKeyMaterial material)
{
if (!string.Equals(material.Format, "base64", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Ed25519 keys must use base64 format.");
}
byte[] rawBytes;
try
{
rawBytes = Convert.FromBase64String(material.Value);
}
catch (FormatException ex)
{
throw new InvalidOperationException("Ed25519 key material must be valid base64.", ex);
}
if (rawBytes.Length != 32)
{
throw new InvalidOperationException("Ed25519 public keys must contain 32 bytes.");
}
return rawBytes;
}
private static byte[] ValidateCertificate(IssuerKeyMaterial material)
{
if (!string.Equals(material.Format, "pem", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(material.Format, "base64", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("X.509 certificates must be provided as PEM or base64.");
}
try
{
if (string.Equals(material.Format, "pem", StringComparison.OrdinalIgnoreCase))
{
using var pemCertificate = X509Certificate2.CreateFromPem(material.Value);
return pemCertificate.RawData;
}
var raw = Convert.FromBase64String(material.Value);
using var loadedCertificate = X509CertificateLoader.LoadCertificate(raw);
return loadedCertificate.RawData;
}
catch (Exception ex) when (ex is CryptographicException || ex is FormatException)
{
throw new InvalidOperationException("Certificate material is invalid or unsupported.", ex);
}
}
private static byte[] ValidateDsseKey(IssuerKeyMaterial material)
{
if (!string.Equals(material.Format, "base64", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("DSSE keys must use base64 format.");
}
byte[] rawBytes;
try
{
rawBytes = Convert.FromBase64String(material.Value);
}
catch (FormatException ex)
{
throw new InvalidOperationException("DSSE key material must be valid base64.", ex);
}
if (rawBytes.Length is not (32 or 48 or 64))
{
throw new InvalidOperationException("DSSE keys must contain 32, 48, or 64 bytes of public key material.");
}
return rawBytes;
}
}

View File

@@ -1,5 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Persistence.Postgres;
@@ -31,11 +30,7 @@ public static class IssuerDirectoryPersistenceExtensions
};
configureOptions(options);
services.AddSingleton(sp =>
{
var logger = sp.GetRequiredService<ILogger<IssuerDirectoryDataSource>>();
return new IssuerDirectoryDataSource(options, logger);
});
RegisterDataSource(services, options);
RegisterRepositories(services);
@@ -54,21 +49,22 @@ public static class IssuerDirectoryPersistenceExtensions
{
ArgumentNullException.ThrowIfNull(options);
// Ensure schema is set for issuer module
RegisterDataSource(services, options);
RegisterRepositories(services);
return services;
}
private static void RegisterDataSource(IServiceCollection services, PostgresOptions options)
{
if (string.IsNullOrWhiteSpace(options.SchemaName))
{
options.SchemaName = "issuer";
}
services.AddSingleton(sp =>
{
var logger = sp.GetRequiredService<ILogger<IssuerDirectoryDataSource>>();
return new IssuerDirectoryDataSource(options, logger);
});
RegisterRepositories(services);
return services;
services.AddSingleton(options);
services.AddSingleton<IssuerDirectoryDataSource>();
}
private static void RegisterRepositories(IServiceCollection services)

View File

@@ -15,7 +15,7 @@ public sealed class PostgresIssuerAuditSink : IIssuerAuditSink
{
private readonly IssuerDirectoryDataSource _dataSource;
private readonly ILogger<PostgresIssuerAuditSink> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
@@ -61,6 +61,6 @@ public sealed class PostgresIssuerAuditSink : IIssuerAuditSink
return "{}";
}
return JsonSerializer.Serialize(metadata, JsonOptions);
return JsonSerializer.Serialize(metadata, _jsonOptions);
}
}

View File

@@ -0,0 +1,75 @@
using Npgsql;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerKeyRepository
{
public async Task<IssuerKeyRecord?> GetAsync(
string tenantId,
string issuerId,
string keyId,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status,
replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at,
revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid AND key_id = @keyId
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
command.Parameters.AddWithValue("keyId", keyId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapToRecord(reader);
}
public async Task<IssuerKeyRecord?> GetByFingerprintAsync(
string tenantId,
string issuerId,
string fingerprint,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status,
replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at,
revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid AND fingerprint = @fingerprint
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
command.Parameters.AddWithValue("fingerprint", fingerprint);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapToRecord(reader);
}
}

View File

@@ -0,0 +1,72 @@
using Npgsql;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerKeyRepository
{
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(
string tenantId,
string issuerId,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status,
replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at,
revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
ORDER BY created_at ASC
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(
string issuerId,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status,
replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at,
revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @globalTenantId::uuid AND issuer_id = @issuerId::uuid
ORDER BY created_at ASC
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("globalTenantId", IssuerTenants.Global);
command.Parameters.AddWithValue("issuerId", issuerId);
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
}
private static async Task<IReadOnlyCollection<IssuerKeyRecord>> ReadAllRecordsAsync(
NpgsqlCommand command,
CancellationToken cancellationToken)
{
var results = new List<IssuerKeyRecord>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecord(reader));
}
return results;
}
}

View File

@@ -0,0 +1,70 @@
using Npgsql;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerKeyRepository
{
private static IssuerKeyRecord MapToRecord(NpgsqlDataReader reader)
{
var issuerId = reader.GetGuid(1).ToString();
var tenantId = reader.GetGuid(2).ToString();
var keyId = reader.GetString(3);
var keyType = ParseKeyType(reader.GetString(4));
var publicKey = reader.GetString(5);
var fingerprint = reader.GetString(6);
var notBefore = reader.IsDBNull(7) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(7), TimeSpan.Zero);
var notAfter = reader.IsDBNull(8) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(8), TimeSpan.Zero);
var status = ParseKeyStatus(reader.GetString(9));
var replacesKeyId = reader.IsDBNull(10) ? null : reader.GetString(10);
var createdAt = reader.GetDateTime(11);
var createdBy = reader.GetString(12);
var updatedAt = reader.GetDateTime(13);
var updatedBy = reader.GetString(14);
var retiredAt = reader.IsDBNull(15) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(15), TimeSpan.Zero);
var revokedAt = reader.IsDBNull(16) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(16), TimeSpan.Zero);
return new IssuerKeyRecord
{
Id = keyId,
IssuerId = issuerId,
TenantId = tenantId,
Type = keyType,
Status = status,
Material = new IssuerKeyMaterial("pem", publicKey),
Fingerprint = fingerprint,
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
CreatedBy = createdBy,
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
UpdatedBy = updatedBy,
ExpiresAtUtc = notAfter,
RetiredAtUtc = retiredAt,
RevokedAtUtc = revokedAt,
ReplacesKeyId = replacesKeyId
};
}
private static string MapKeyType(IssuerKeyType type) => type switch
{
IssuerKeyType.Ed25519PublicKey => "ed25519",
IssuerKeyType.X509Certificate => "x509",
IssuerKeyType.DssePublicKey => "dsse",
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unsupported key type")
};
private static IssuerKeyType ParseKeyType(string value) => value.ToLowerInvariant() switch
{
"ed25519" => IssuerKeyType.Ed25519PublicKey,
"x509" => IssuerKeyType.X509Certificate,
"dsse" => IssuerKeyType.DssePublicKey,
_ => throw new ArgumentException($"Unknown key type: {value}", nameof(value))
};
private static IssuerKeyStatus ParseKeyStatus(string value) => value.ToLowerInvariant() switch
{
"active" => IssuerKeyStatus.Active,
"retired" => IssuerKeyStatus.Retired,
"revoked" => IssuerKeyStatus.Revoked,
_ => throw new ArgumentException($"Unknown key status: {value}", nameof(value))
};
}

View File

@@ -0,0 +1,90 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerKeyRepository
{
public async Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
await using var connection = await _dataSource
.OpenConnectionAsync(record.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
const string sql = """
INSERT INTO issuer.issuer_keys (id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint,
not_before, not_after, status, replaces_key_id, created_at, created_by,
updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata)
VALUES (@id::uuid, @issuerId::uuid, @tenantId::uuid, @keyId, @keyType, @publicKey, @fingerprint,
@notBefore, @notAfter, @status, @replacesKeyId, @createdAt, @createdBy, @updatedAt, @updatedBy,
@retiredAt, @revokedAt, @revokeReason, @metadata::jsonb)
ON CONFLICT (issuer_id, key_id)
DO UPDATE SET
key_type = EXCLUDED.key_type,
public_key = EXCLUDED.public_key,
fingerprint = EXCLUDED.fingerprint,
not_before = EXCLUDED.not_before,
not_after = EXCLUDED.not_after,
status = EXCLUDED.status,
replaces_key_id = EXCLUDED.replaces_key_id,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by,
retired_at = EXCLUDED.retired_at,
revoked_at = EXCLUDED.revoked_at,
revoke_reason = EXCLUDED.revoke_reason,
metadata = EXCLUDED.metadata
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("id", Guid.Parse(record.Id));
command.Parameters.AddWithValue("issuerId", Guid.Parse(record.IssuerId));
command.Parameters.AddWithValue("tenantId", Guid.Parse(record.TenantId));
command.Parameters.AddWithValue("keyId", record.Id);
command.Parameters.AddWithValue("keyType", MapKeyType(record.Type));
command.Parameters.AddWithValue("publicKey", record.Material.Value);
command.Parameters.AddWithValue("fingerprint", record.Fingerprint);
command.Parameters.Add(new NpgsqlParameter("notBefore", NpgsqlDbType.TimestampTz)
{
Value = (object?)null ?? DBNull.Value
});
command.Parameters.Add(new NpgsqlParameter("notAfter", NpgsqlDbType.TimestampTz)
{
Value = record.ExpiresAtUtc.HasValue ? record.ExpiresAtUtc.Value.UtcDateTime : DBNull.Value
});
command.Parameters.AddWithValue("status", record.Status.ToString().ToLowerInvariant());
command.Parameters.Add(new NpgsqlParameter("replacesKeyId", NpgsqlDbType.Text)
{
Value = record.ReplacesKeyId ?? (object)DBNull.Value
});
command.Parameters.AddWithValue("createdAt", record.CreatedAtUtc.UtcDateTime);
command.Parameters.AddWithValue("createdBy", record.CreatedBy);
command.Parameters.AddWithValue("updatedAt", record.UpdatedAtUtc.UtcDateTime);
command.Parameters.AddWithValue("updatedBy", record.UpdatedBy);
command.Parameters.Add(new NpgsqlParameter("retiredAt", NpgsqlDbType.TimestampTz)
{
Value = record.RetiredAtUtc.HasValue ? record.RetiredAtUtc.Value.UtcDateTime : DBNull.Value
});
command.Parameters.Add(new NpgsqlParameter("revokedAt", NpgsqlDbType.TimestampTz)
{
Value = record.RevokedAtUtc.HasValue ? record.RevokedAtUtc.Value.UtcDateTime : DBNull.Value
});
command.Parameters.Add(new NpgsqlParameter("revokeReason", NpgsqlDbType.Text)
{
Value = DBNull.Value
});
command.Parameters.Add(new NpgsqlParameter("metadata", NpgsqlDbType.Jsonb)
{
Value = "{}"
});
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Upserted issuer key {KeyId} for issuer {IssuerId}.", record.Id, record.IssuerId);
}
}

View File

@@ -1,241 +1,22 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.Postgres;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the issuer key repository.
/// </summary>
public sealed class PostgresIssuerKeyRepository : IIssuerKeyRepository
public sealed partial class PostgresIssuerKeyRepository : IIssuerKeyRepository
{
private readonly IssuerDirectoryDataSource _dataSource;
private readonly ILogger<PostgresIssuerKeyRepository> _logger;
public PostgresIssuerKeyRepository(IssuerDirectoryDataSource dataSource, ILogger<PostgresIssuerKeyRepository> logger)
public PostgresIssuerKeyRepository(
IssuerDirectoryDataSource dataSource,
ILogger<PostgresIssuerKeyRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IssuerKeyRecord?> GetAsync(string tenantId, string issuerId, string keyId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid AND key_id = @keyId
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
command.Parameters.AddWithValue("keyId", keyId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapToRecord(reader);
}
public async Task<IssuerKeyRecord?> GetByFingerprintAsync(string tenantId, string issuerId, string fingerprint, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid AND fingerprint = @fingerprint
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
command.Parameters.AddWithValue("fingerprint", fingerprint);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapToRecord(reader);
}
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
ORDER BY created_at ASC
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @globalTenantId::uuid AND issuer_id = @issuerId::uuid
ORDER BY created_at ASC
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("globalTenantId", IssuerTenants.Global);
command.Parameters.AddWithValue("issuerId", issuerId);
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
}
public async Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "writer", cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO issuer.issuer_keys (id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata)
VALUES (@id::uuid, @issuerId::uuid, @tenantId::uuid, @keyId, @keyType, @publicKey, @fingerprint, @notBefore, @notAfter, @status, @replacesKeyId, @createdAt, @createdBy, @updatedAt, @updatedBy, @retiredAt, @revokedAt, @revokeReason, @metadata::jsonb)
ON CONFLICT (issuer_id, key_id)
DO UPDATE SET
key_type = EXCLUDED.key_type,
public_key = EXCLUDED.public_key,
fingerprint = EXCLUDED.fingerprint,
not_before = EXCLUDED.not_before,
not_after = EXCLUDED.not_after,
status = EXCLUDED.status,
replaces_key_id = EXCLUDED.replaces_key_id,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by,
retired_at = EXCLUDED.retired_at,
revoked_at = EXCLUDED.revoked_at,
revoke_reason = EXCLUDED.revoke_reason,
metadata = EXCLUDED.metadata
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("id", Guid.Parse(record.Id));
command.Parameters.AddWithValue("issuerId", Guid.Parse(record.IssuerId));
command.Parameters.AddWithValue("tenantId", Guid.Parse(record.TenantId));
command.Parameters.AddWithValue("keyId", record.Id);
command.Parameters.AddWithValue("keyType", MapKeyType(record.Type));
command.Parameters.AddWithValue("publicKey", record.Material.Value);
command.Parameters.AddWithValue("fingerprint", record.Fingerprint);
command.Parameters.Add(new NpgsqlParameter("notBefore", NpgsqlDbType.TimestampTz) { Value = (object?)null ?? DBNull.Value });
command.Parameters.Add(new NpgsqlParameter("notAfter", NpgsqlDbType.TimestampTz) { Value = record.ExpiresAtUtc.HasValue ? record.ExpiresAtUtc.Value.UtcDateTime : DBNull.Value });
command.Parameters.AddWithValue("status", record.Status.ToString().ToLowerInvariant());
command.Parameters.Add(new NpgsqlParameter("replacesKeyId", NpgsqlDbType.Text) { Value = record.ReplacesKeyId ?? (object)DBNull.Value });
command.Parameters.AddWithValue("createdAt", record.CreatedAtUtc.UtcDateTime);
command.Parameters.AddWithValue("createdBy", record.CreatedBy);
command.Parameters.AddWithValue("updatedAt", record.UpdatedAtUtc.UtcDateTime);
command.Parameters.AddWithValue("updatedBy", record.UpdatedBy);
command.Parameters.Add(new NpgsqlParameter("retiredAt", NpgsqlDbType.TimestampTz) { Value = record.RetiredAtUtc.HasValue ? record.RetiredAtUtc.Value.UtcDateTime : DBNull.Value });
command.Parameters.Add(new NpgsqlParameter("revokedAt", NpgsqlDbType.TimestampTz) { Value = record.RevokedAtUtc.HasValue ? record.RevokedAtUtc.Value.UtcDateTime : DBNull.Value });
command.Parameters.Add(new NpgsqlParameter("revokeReason", NpgsqlDbType.Text) { Value = DBNull.Value });
command.Parameters.Add(new NpgsqlParameter("metadata", NpgsqlDbType.Jsonb) { Value = "{}" });
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Upserted issuer key {KeyId} for issuer {IssuerId}.", record.Id, record.IssuerId);
}
private static async Task<IReadOnlyCollection<IssuerKeyRecord>> ReadAllRecordsAsync(NpgsqlCommand command, CancellationToken cancellationToken)
{
var results = new List<IssuerKeyRecord>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecord(reader));
}
return results;
}
private static IssuerKeyRecord MapToRecord(NpgsqlDataReader reader)
{
var id = reader.GetGuid(0).ToString();
var issuerId = reader.GetGuid(1).ToString();
var tenantId = reader.GetGuid(2).ToString();
var keyId = reader.GetString(3);
var keyType = ParseKeyType(reader.GetString(4));
var publicKey = reader.GetString(5);
var fingerprint = reader.GetString(6);
var notBefore = reader.IsDBNull(7) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(7), TimeSpan.Zero);
var notAfter = reader.IsDBNull(8) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(8), TimeSpan.Zero);
var status = ParseKeyStatus(reader.GetString(9));
var replacesKeyId = reader.IsDBNull(10) ? null : reader.GetString(10);
var createdAt = reader.GetDateTime(11);
var createdBy = reader.GetString(12);
var updatedAt = reader.GetDateTime(13);
var updatedBy = reader.GetString(14);
var retiredAt = reader.IsDBNull(15) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(15), TimeSpan.Zero);
var revokedAt = reader.IsDBNull(16) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(16), TimeSpan.Zero);
return new IssuerKeyRecord
{
Id = keyId,
IssuerId = issuerId,
TenantId = tenantId,
Type = keyType,
Status = status,
Material = new IssuerKeyMaterial("pem", publicKey),
Fingerprint = fingerprint,
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
CreatedBy = createdBy,
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
UpdatedBy = updatedBy,
ExpiresAtUtc = notAfter,
RetiredAtUtc = retiredAt,
RevokedAtUtc = revokedAt,
ReplacesKeyId = replacesKeyId
};
}
private static string MapKeyType(IssuerKeyType type) => type switch
{
IssuerKeyType.Ed25519PublicKey => "ed25519",
IssuerKeyType.X509Certificate => "x509",
IssuerKeyType.DssePublicKey => "dsse",
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unsupported key type")
};
private static IssuerKeyType ParseKeyType(string value) => value.ToLowerInvariant() switch
{
"ed25519" => IssuerKeyType.Ed25519PublicKey,
"x509" => IssuerKeyType.X509Certificate,
"dsse" => IssuerKeyType.DssePublicKey,
_ => throw new ArgumentException($"Unknown key type: {value}", nameof(value))
};
private static IssuerKeyStatus ParseKeyStatus(string value) => value.ToLowerInvariant() switch
{
"active" => IssuerKeyStatus.Active,
"retired" => IssuerKeyStatus.Retired,
"revoked" => IssuerKeyStatus.Revoked,
_ => throw new ArgumentException($"Unknown key status: {value}", nameof(value))
};
}

View File

@@ -0,0 +1,40 @@
using System.Text.Json;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
private static string SerializeContact(IssuerContact contact)
{
var doc = new
{
email = contact.Email,
phone = contact.Phone,
website = contact.Website?.ToString(),
timezone = contact.Timezone
};
return JsonSerializer.Serialize(doc, _jsonOptions);
}
private static IssuerContact DeserializeContact(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var email = root.TryGetProperty("email", out var e) && e.ValueKind != JsonValueKind.Null ? e.GetString() : null;
var phone = root.TryGetProperty("phone", out var p) && p.ValueKind != JsonValueKind.Null ? p.GetString() : null;
var websiteStr = root.TryGetProperty("website", out var w) && w.ValueKind != JsonValueKind.Null
? w.GetString()
: null;
var timezone = root.TryGetProperty("timezone", out var t) && t.ValueKind != JsonValueKind.Null
? t.GetString()
: null;
return new IssuerContact(
email,
phone,
string.IsNullOrWhiteSpace(websiteStr) ? null : new Uri(websiteStr),
timezone);
}
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
private static string SerializeEndpoints(IReadOnlyCollection<IssuerEndpoint> endpoints)
{
var docs = endpoints.Select(endpoint => new
{
kind = endpoint.Kind,
url = endpoint.Url.ToString(),
format = endpoint.Format,
requiresAuthentication = endpoint.RequiresAuthentication
}).ToList();
return JsonSerializer.Serialize(docs, _jsonOptions);
}
private static IReadOnlyCollection<IssuerEndpoint> DeserializeEndpoints(string json)
{
var results = new List<IssuerEndpoint>();
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
return results;
}
foreach (var elem in doc.RootElement.EnumerateArray())
{
var kind = elem.TryGetProperty("kind", out var k) ? k.GetString() : null;
var urlStr = elem.TryGetProperty("url", out var u) ? u.GetString() : null;
var format = elem.TryGetProperty("format", out var f) && f.ValueKind != JsonValueKind.Null
? f.GetString()
: null;
var requiresAuth = elem.TryGetProperty("requiresAuthentication", out var ra) && ra.GetBoolean();
if (!string.IsNullOrWhiteSpace(kind) && !string.IsNullOrWhiteSpace(urlStr))
{
results.Add(new IssuerEndpoint(kind, new Uri(urlStr), format, requiresAuth));
}
}
return results;
}
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}

View File

@@ -0,0 +1,47 @@
using Npgsql;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
private static IssuerRecord MapToRecord(NpgsqlDataReader reader)
{
var id = reader.GetGuid(0).ToString();
var tenantId = reader.GetGuid(1).ToString();
var name = reader.GetString(2);
var displayName = reader.GetString(3);
var description = reader.IsDBNull(4) ? null : reader.GetString(4);
var endpointsJson = reader.GetString(5);
var contactJson = reader.GetString(6);
var metadataJson = reader.GetString(7);
var tags = reader.GetFieldValue<string[]>(8);
var isSystemSeed = reader.GetBoolean(10);
var createdAt = reader.GetDateTime(11);
var createdBy = reader.GetString(12);
var updatedAt = reader.GetDateTime(13);
var updatedBy = reader.GetString(14);
var contact = DeserializeContact(contactJson);
var metadata = DeserializeMetadata(metadataJson);
var endpoints = DeserializeEndpoints(endpointsJson);
return new IssuerRecord
{
Id = id,
TenantId = tenantId,
Slug = name,
DisplayName = displayName,
Description = description,
Contact = contact,
Metadata = metadata,
Endpoints = endpoints,
Tags = tags,
IsSystemSeed = isSystemSeed,
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
CreatedBy = createdBy,
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
UpdatedBy = updatedBy
};
}
}

View File

@@ -0,0 +1,73 @@
using System.Text.Json;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
private static string SerializeMetadata(IssuerMetadata metadata)
{
var doc = new
{
cveOrgId = metadata.CveOrgId,
csafPublisherId = metadata.CsafPublisherId,
securityAdvisoriesUrl = metadata.SecurityAdvisoriesUrl?.ToString(),
catalogUrl = metadata.CatalogUrl?.ToString(),
languages = metadata.SupportedLanguages.ToList(),
attributes = new Dictionary<string, string>(metadata.Attributes)
};
return JsonSerializer.Serialize(doc, _jsonOptions);
}
private static IssuerMetadata DeserializeMetadata(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var cveOrgId = root.TryGetProperty("cveOrgId", out var c) && c.ValueKind != JsonValueKind.Null
? c.GetString()
: null;
var csafPublisherId = root.TryGetProperty("csafPublisherId", out var cp) && cp.ValueKind != JsonValueKind.Null
? cp.GetString()
: null;
var securityAdvisoriesUrlStr = root.TryGetProperty("securityAdvisoriesUrl", out var sa) &&
sa.ValueKind != JsonValueKind.Null
? sa.GetString()
: null;
var catalogUrlStr = root.TryGetProperty("catalogUrl", out var cu) && cu.ValueKind != JsonValueKind.Null
? cu.GetString()
: null;
var languages = new List<string>();
if (root.TryGetProperty("languages", out var langs) && langs.ValueKind == JsonValueKind.Array)
{
foreach (var lang in langs.EnumerateArray())
{
if (lang.ValueKind == JsonValueKind.String)
{
languages.Add(lang.GetString()!);
}
}
}
var attributes = new Dictionary<string, string>();
if (root.TryGetProperty("attributes", out var attrs) && attrs.ValueKind == JsonValueKind.Object)
{
foreach (var prop in attrs.EnumerateObject())
{
if (prop.Value.ValueKind == JsonValueKind.String)
{
attributes[prop.Name] = prop.Value.GetString()!;
}
}
}
return new IssuerMetadata(
cveOrgId,
csafPublisherId,
string.IsNullOrWhiteSpace(securityAdvisoriesUrlStr) ? null : new Uri(securityAdvisoriesUrlStr),
string.IsNullOrWhiteSpace(catalogUrlStr) ? null : new Uri(catalogUrlStr),
languages,
attributes);
}
}

View File

@@ -0,0 +1,96 @@
using Npgsql;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
public async Task<IssuerRecord?> GetAsync(
string tenantId,
string issuerId,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
const string sql = """
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status,
is_system_seed, created_at, created_by, updated_at, updated_by
FROM issuer.issuers
WHERE tenant_id = @tenantId::uuid AND id = @issuerId::uuid
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapToRecord(reader);
}
public async Task<IReadOnlyCollection<IssuerRecord>> ListAsync(
string tenantId,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
const string sql = """
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status,
is_system_seed, created_at, created_by, updated_at, updated_by
FROM issuer.issuers
WHERE tenant_id = @tenantId::uuid
ORDER BY name ASC
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
const string sql = """
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status,
is_system_seed, created_at, created_by, updated_at, updated_by
FROM issuer.issuers
WHERE tenant_id = @globalTenantId::uuid
ORDER BY name ASC
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("globalTenantId", IssuerTenants.Global);
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
}
private static async Task<IReadOnlyCollection<IssuerRecord>> ReadAllRecordsAsync(
NpgsqlCommand command,
CancellationToken cancellationToken)
{
var results = new List<IssuerRecord>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecord(reader));
}
return results;
}
}

View File

@@ -0,0 +1,95 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerRepository
{
public async Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
await using var connection = await _dataSource
.OpenConnectionAsync(record.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
const string sql = """
INSERT INTO issuer.issuers (id, tenant_id, name, display_name, description, endpoints, contact, metadata,
tags, status, is_system_seed, created_at, created_by, updated_at, updated_by)
VALUES (@id::uuid, @tenantId::uuid, @name, @displayName, @description, @endpoints::jsonb, @contact::jsonb,
@metadata::jsonb, @tags, @status, @isSystemSeed, @createdAt, @createdBy, @updatedAt, @updatedBy)
ON CONFLICT (tenant_id, name)
DO UPDATE SET
display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
endpoints = EXCLUDED.endpoints,
contact = EXCLUDED.contact,
metadata = EXCLUDED.metadata,
tags = EXCLUDED.tags,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("id", Guid.Parse(record.Id));
command.Parameters.AddWithValue("tenantId", Guid.Parse(record.TenantId));
command.Parameters.AddWithValue("name", record.Slug);
command.Parameters.AddWithValue("displayName", record.DisplayName);
command.Parameters.Add(new NpgsqlParameter("description", NpgsqlDbType.Text)
{
Value = record.Description ?? (object)DBNull.Value
});
command.Parameters.Add(new NpgsqlParameter("endpoints", NpgsqlDbType.Jsonb)
{
Value = SerializeEndpoints(record.Endpoints)
});
command.Parameters.Add(new NpgsqlParameter("contact", NpgsqlDbType.Jsonb)
{
Value = SerializeContact(record.Contact)
});
command.Parameters.Add(new NpgsqlParameter("metadata", NpgsqlDbType.Jsonb)
{
Value = SerializeMetadata(record.Metadata)
});
command.Parameters.Add(new NpgsqlParameter("tags", NpgsqlDbType.Array | NpgsqlDbType.Text)
{
Value = record.Tags.ToArray()
});
command.Parameters.AddWithValue("status", "active");
command.Parameters.AddWithValue("isSystemSeed", record.IsSystemSeed);
command.Parameters.AddWithValue("createdAt", record.CreatedAtUtc);
command.Parameters.AddWithValue("createdBy", record.CreatedBy);
command.Parameters.AddWithValue("updatedAt", record.UpdatedAtUtc);
command.Parameters.AddWithValue("updatedBy", record.UpdatedBy);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Upserted issuer {IssuerId} for tenant {TenantId}.", record.Id, record.TenantId);
}
public async Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
const string sql = "DELETE FROM issuer.issuers WHERE tenant_id = @tenantId::uuid AND id = @issuerId::uuid";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Deleted issuer {IssuerId} for tenant {TenantId}. Rows affected: {Rows}.",
issuerId,
tenantId,
rowsAffected);
}
}

View File

@@ -1,318 +1,22 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using System.Text.Json;
using StellaOps.IssuerDirectory.Persistence.Postgres;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the issuer repository.
/// </summary>
public sealed class PostgresIssuerRepository : IIssuerRepository
public sealed partial class PostgresIssuerRepository : IIssuerRepository
{
private readonly IssuerDirectoryDataSource _dataSource;
private readonly ILogger<PostgresIssuerRepository> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public PostgresIssuerRepository(IssuerDirectoryDataSource dataSource, ILogger<PostgresIssuerRepository> logger)
public PostgresIssuerRepository(
IssuerDirectoryDataSource dataSource,
ILogger<PostgresIssuerRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status, is_system_seed, created_at, created_by, updated_at, updated_by
FROM issuer.issuers
WHERE tenant_id = @tenantId::uuid AND id = @issuerId::uuid
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapToRecord(reader);
}
public async Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status, is_system_seed, created_at, created_by, updated_at, updated_by
FROM issuer.issuers
WHERE tenant_id = @tenantId::uuid
ORDER BY name ASC
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status, is_system_seed, created_at, created_by, updated_at, updated_by
FROM issuer.issuers
WHERE tenant_id = @globalTenantId::uuid
ORDER BY name ASC
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("globalTenantId", IssuerTenants.Global);
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
}
public async Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "writer", cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO issuer.issuers (id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status, is_system_seed, created_at, created_by, updated_at, updated_by)
VALUES (@id::uuid, @tenantId::uuid, @name, @displayName, @description, @endpoints::jsonb, @contact::jsonb, @metadata::jsonb, @tags, @status, @isSystemSeed, @createdAt, @createdBy, @updatedAt, @updatedBy)
ON CONFLICT (tenant_id, name)
DO UPDATE SET
display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
endpoints = EXCLUDED.endpoints,
contact = EXCLUDED.contact,
metadata = EXCLUDED.metadata,
tags = EXCLUDED.tags,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("id", Guid.Parse(record.Id));
command.Parameters.AddWithValue("tenantId", Guid.Parse(record.TenantId));
command.Parameters.AddWithValue("name", record.Slug);
command.Parameters.AddWithValue("displayName", record.DisplayName);
command.Parameters.Add(new NpgsqlParameter("description", NpgsqlDbType.Text) { Value = record.Description ?? (object)DBNull.Value });
command.Parameters.Add(new NpgsqlParameter("endpoints", NpgsqlDbType.Jsonb) { Value = SerializeEndpoints(record.Endpoints) });
command.Parameters.Add(new NpgsqlParameter("contact", NpgsqlDbType.Jsonb) { Value = SerializeContact(record.Contact) });
command.Parameters.Add(new NpgsqlParameter("metadata", NpgsqlDbType.Jsonb) { Value = SerializeMetadata(record.Metadata) });
command.Parameters.Add(new NpgsqlParameter("tags", NpgsqlDbType.Array | NpgsqlDbType.Text) { Value = record.Tags.ToArray() });
command.Parameters.AddWithValue("status", "active");
command.Parameters.AddWithValue("isSystemSeed", record.IsSystemSeed);
command.Parameters.AddWithValue("createdAt", record.CreatedAtUtc);
command.Parameters.AddWithValue("createdBy", record.CreatedBy);
command.Parameters.AddWithValue("updatedAt", record.UpdatedAtUtc);
command.Parameters.AddWithValue("updatedBy", record.UpdatedBy);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Upserted issuer {IssuerId} for tenant {TenantId}.", record.Id, record.TenantId);
}
public async Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
const string sql = "DELETE FROM issuer.issuers WHERE tenant_id = @tenantId::uuid AND id = @issuerId::uuid";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Deleted issuer {IssuerId} for tenant {TenantId}. Rows affected: {Rows}.", issuerId, tenantId, rowsAffected);
}
private static async Task<IReadOnlyCollection<IssuerRecord>> ReadAllRecordsAsync(NpgsqlCommand command, CancellationToken cancellationToken)
{
var results = new List<IssuerRecord>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecord(reader));
}
return results;
}
private static IssuerRecord MapToRecord(NpgsqlDataReader reader)
{
var id = reader.GetGuid(0).ToString();
var tenantId = reader.GetGuid(1).ToString();
var name = reader.GetString(2);
var displayName = reader.GetString(3);
var description = reader.IsDBNull(4) ? null : reader.GetString(4);
var endpointsJson = reader.GetString(5);
var contactJson = reader.GetString(6);
var metadataJson = reader.GetString(7);
var tags = reader.GetFieldValue<string[]>(8);
var isSystemSeed = reader.GetBoolean(10);
var createdAt = reader.GetDateTime(11);
var createdBy = reader.GetString(12);
var updatedAt = reader.GetDateTime(13);
var updatedBy = reader.GetString(14);
var contact = DeserializeContact(contactJson);
var metadata = DeserializeMetadata(metadataJson);
var endpoints = DeserializeEndpoints(endpointsJson);
return new IssuerRecord
{
Id = id,
TenantId = tenantId,
Slug = name,
DisplayName = displayName,
Description = description,
Contact = contact,
Metadata = metadata,
Endpoints = endpoints,
Tags = tags,
IsSystemSeed = isSystemSeed,
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
CreatedBy = createdBy,
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
UpdatedBy = updatedBy
};
}
private static string SerializeContact(IssuerContact contact)
{
var doc = new
{
email = contact.Email,
phone = contact.Phone,
website = contact.Website?.ToString(),
timezone = contact.Timezone
};
return JsonSerializer.Serialize(doc, JsonOptions);
}
private static IssuerContact DeserializeContact(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var email = root.TryGetProperty("email", out var e) && e.ValueKind != JsonValueKind.Null ? e.GetString() : null;
var phone = root.TryGetProperty("phone", out var p) && p.ValueKind != JsonValueKind.Null ? p.GetString() : null;
var websiteStr = root.TryGetProperty("website", out var w) && w.ValueKind != JsonValueKind.Null ? w.GetString() : null;
var timezone = root.TryGetProperty("timezone", out var t) && t.ValueKind != JsonValueKind.Null ? t.GetString() : null;
return new IssuerContact(email, phone, string.IsNullOrWhiteSpace(websiteStr) ? null : new Uri(websiteStr), timezone);
}
private static string SerializeMetadata(IssuerMetadata metadata)
{
var doc = new
{
cveOrgId = metadata.CveOrgId,
csafPublisherId = metadata.CsafPublisherId,
securityAdvisoriesUrl = metadata.SecurityAdvisoriesUrl?.ToString(),
catalogUrl = metadata.CatalogUrl?.ToString(),
languages = metadata.SupportedLanguages.ToList(),
attributes = new Dictionary<string, string>(metadata.Attributes)
};
return JsonSerializer.Serialize(doc, JsonOptions);
}
private static IssuerMetadata DeserializeMetadata(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var cveOrgId = root.TryGetProperty("cveOrgId", out var c) && c.ValueKind != JsonValueKind.Null ? c.GetString() : null;
var csafPublisherId = root.TryGetProperty("csafPublisherId", out var cp) && cp.ValueKind != JsonValueKind.Null ? cp.GetString() : null;
var securityAdvisoriesUrlStr = root.TryGetProperty("securityAdvisoriesUrl", out var sa) && sa.ValueKind != JsonValueKind.Null ? sa.GetString() : null;
var catalogUrlStr = root.TryGetProperty("catalogUrl", out var cu) && cu.ValueKind != JsonValueKind.Null ? cu.GetString() : null;
var languages = new List<string>();
if (root.TryGetProperty("languages", out var langs) && langs.ValueKind == JsonValueKind.Array)
{
foreach (var lang in langs.EnumerateArray())
{
if (lang.ValueKind == JsonValueKind.String)
{
languages.Add(lang.GetString()!);
}
}
}
var attributes = new Dictionary<string, string>();
if (root.TryGetProperty("attributes", out var attrs) && attrs.ValueKind == JsonValueKind.Object)
{
foreach (var prop in attrs.EnumerateObject())
{
if (prop.Value.ValueKind == JsonValueKind.String)
{
attributes[prop.Name] = prop.Value.GetString()!;
}
}
}
return new IssuerMetadata(
cveOrgId,
csafPublisherId,
string.IsNullOrWhiteSpace(securityAdvisoriesUrlStr) ? null : new Uri(securityAdvisoriesUrlStr),
string.IsNullOrWhiteSpace(catalogUrlStr) ? null : new Uri(catalogUrlStr),
languages,
attributes);
}
private static string SerializeEndpoints(IReadOnlyCollection<IssuerEndpoint> endpoints)
{
var docs = endpoints.Select(e => new
{
kind = e.Kind,
url = e.Url.ToString(),
format = e.Format,
requiresAuthentication = e.RequiresAuthentication
}).ToList();
return JsonSerializer.Serialize(docs, JsonOptions);
}
private static IReadOnlyCollection<IssuerEndpoint> DeserializeEndpoints(string json)
{
var results = new List<IssuerEndpoint>();
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
return results;
}
foreach (var elem in doc.RootElement.EnumerateArray())
{
var kind = elem.TryGetProperty("kind", out var k) ? k.GetString() : null;
var urlStr = elem.TryGetProperty("url", out var u) ? u.GetString() : null;
var format = elem.TryGetProperty("format", out var f) && f.ValueKind != JsonValueKind.Null ? f.GetString() : null;
var requiresAuth = elem.TryGetProperty("requiresAuthentication", out var ra) && ra.GetBoolean();
if (!string.IsNullOrWhiteSpace(kind) && !string.IsNullOrWhiteSpace(urlStr))
{
results.Add(new IssuerEndpoint(kind, new Uri(urlStr), format, requiresAuth));
}
}
return results;
}
}

View File

@@ -0,0 +1,31 @@
using Npgsql;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerTrustRepository
{
private static IssuerTrustOverrideRecord MapToRecord(NpgsqlDataReader reader)
{
var issuerId = reader.GetGuid(1).ToString();
var tenantId = reader.GetGuid(2).ToString();
var weight = reader.GetDecimal(3);
var rationale = reader.IsDBNull(4) ? null : reader.GetString(4);
var createdAt = reader.GetDateTime(6);
var createdBy = reader.GetString(7);
var updatedAt = reader.GetDateTime(8);
var updatedBy = reader.GetString(9);
return new IssuerTrustOverrideRecord
{
IssuerId = issuerId,
TenantId = tenantId,
Weight = weight,
Reason = rationale,
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
CreatedBy = createdBy,
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
UpdatedBy = updatedBy
};
}
}

View File

@@ -0,0 +1,38 @@
using Npgsql;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerTrustRepository
{
public async Task<IssuerTrustOverrideRecord?> GetAsync(
string tenantId,
string issuerId,
CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, weight, rationale, expires_at, created_at, created_by, updated_at,
updated_by
FROM issuer.trust_overrides
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapToRecord(reader);
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
public sealed partial class PostgresIssuerTrustRepository
{
public async Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
await using var connection = await _dataSource
.OpenConnectionAsync(record.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
const string sql = """
INSERT INTO issuer.trust_overrides (issuer_id, tenant_id, weight, rationale, created_at, created_by,
updated_at, updated_by)
VALUES (@issuerId::uuid, @tenantId::uuid, @weight, @rationale, @createdAt, @createdBy, @updatedAt,
@updatedBy)
ON CONFLICT (issuer_id, tenant_id)
DO UPDATE SET
weight = EXCLUDED.weight,
rationale = EXCLUDED.rationale,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("issuerId", Guid.Parse(record.IssuerId));
command.Parameters.AddWithValue("tenantId", Guid.Parse(record.TenantId));
command.Parameters.AddWithValue("weight", record.Weight);
command.Parameters.Add(new NpgsqlParameter("rationale", NpgsqlDbType.Text)
{
Value = record.Reason ?? (object)DBNull.Value
});
command.Parameters.AddWithValue("createdAt", record.CreatedAtUtc.UtcDateTime);
command.Parameters.AddWithValue("createdBy", record.CreatedBy);
command.Parameters.AddWithValue("updatedAt", record.UpdatedAtUtc.UtcDateTime);
command.Parameters.AddWithValue("updatedBy", record.UpdatedBy);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Upserted trust override for issuer {IssuerId} in tenant {TenantId}.",
record.IssuerId,
record.TenantId);
}
public async Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource
.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
const string sql = "DELETE FROM issuer.trust_overrides WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Deleted trust override for issuer {IssuerId} in tenant {TenantId}. Rows affected: {Rows}.",
issuerId,
tenantId,
rowsAffected);
}
}

View File

@@ -1,120 +1,22 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.Postgres;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the issuer trust repository.
/// </summary>
public sealed class PostgresIssuerTrustRepository : IIssuerTrustRepository
public sealed partial class PostgresIssuerTrustRepository : IIssuerTrustRepository
{
private readonly IssuerDirectoryDataSource _dataSource;
private readonly ILogger<PostgresIssuerTrustRepository> _logger;
public PostgresIssuerTrustRepository(IssuerDirectoryDataSource dataSource, ILogger<PostgresIssuerTrustRepository> logger)
public PostgresIssuerTrustRepository(
IssuerDirectoryDataSource dataSource,
ILogger<PostgresIssuerTrustRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IssuerTrustOverrideRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, weight, rationale, expires_at, created_at, created_by, updated_at, updated_by
FROM issuer.trust_overrides
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapToRecord(reader);
}
public async Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "writer", cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO issuer.trust_overrides (issuer_id, tenant_id, weight, rationale, created_at, created_by, updated_at, updated_by)
VALUES (@issuerId::uuid, @tenantId::uuid, @weight, @rationale, @createdAt, @createdBy, @updatedAt, @updatedBy)
ON CONFLICT (issuer_id, tenant_id)
DO UPDATE SET
weight = EXCLUDED.weight,
rationale = EXCLUDED.rationale,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("issuerId", Guid.Parse(record.IssuerId));
command.Parameters.AddWithValue("tenantId", Guid.Parse(record.TenantId));
command.Parameters.AddWithValue("weight", record.Weight);
command.Parameters.Add(new NpgsqlParameter("rationale", NpgsqlDbType.Text) { Value = record.Reason ?? (object)DBNull.Value });
command.Parameters.AddWithValue("createdAt", record.CreatedAtUtc.UtcDateTime);
command.Parameters.AddWithValue("createdBy", record.CreatedBy);
command.Parameters.AddWithValue("updatedAt", record.UpdatedAtUtc.UtcDateTime);
command.Parameters.AddWithValue("updatedBy", record.UpdatedBy);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Upserted trust override for issuer {IssuerId} in tenant {TenantId}.", record.IssuerId, record.TenantId);
}
public async Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
const string sql = "DELETE FROM issuer.trust_overrides WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Deleted trust override for issuer {IssuerId} in tenant {TenantId}. Rows affected: {Rows}.", issuerId, tenantId, rowsAffected);
}
private static IssuerTrustOverrideRecord MapToRecord(NpgsqlDataReader reader)
{
var issuerId = reader.GetGuid(1).ToString();
var tenantId = reader.GetGuid(2).ToString();
var weight = reader.GetDecimal(3);
var rationale = reader.IsDBNull(4) ? null : reader.GetString(4);
var createdAt = reader.GetDateTime(6);
var createdBy = reader.GetString(7);
var updatedAt = reader.GetDateTime(8);
var updatedBy = reader.GetString(9);
return new IssuerTrustOverrideRecord
{
IssuerId = issuerId,
TenantId = tenantId,
Weight = weight,
Reason = rationale,
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
CreatedBy = createdBy,
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
UpdatedBy = updatedBy
};
}
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0376-T | DONE | Revalidated 2026-01-07; test coverage audit for IssuerDirectory.Persistence. |
| AUDIT-0376-A | TODO | Pending approval (revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-07 | DONE | 2026-02-04: Split repositories into partials, removed service locator registration, expanded persistence tests (SPRINT_20260130_002). |

View File

@@ -0,0 +1,47 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public sealed partial class IssuerAuditSinkTests
{
private async Task<string> SeedIssuerAsync()
{
var issuerId = Guid.NewGuid().ToString();
var now = DateTimeOffset.UtcNow;
var issuer = new IssuerRecord
{
Id = issuerId,
TenantId = _tenantId,
Slug = $"test-issuer-{Guid.NewGuid():N}",
DisplayName = "Test Issuer",
Description = "Test issuer for audit tests",
Contact = new IssuerContact(null, null, null, null),
Metadata = new IssuerMetadata(null, null, null, null, [], new Dictionary<string, string>()),
Endpoints = [],
Tags = [],
IsSystemSeed = false,
CreatedAtUtc = now,
CreatedBy = "test@test.com",
UpdatedAtUtc = now,
UpdatedBy = "test@test.com"
};
await _issuerRepository.UpsertAsync(issuer, CancellationToken.None);
return issuerId;
}
private IssuerAuditEntry CreateAuditEntry(
string action,
string? reason,
IReadOnlyDictionary<string, string>? metadata = null,
DateTimeOffset? timestamp = null)
{
return new IssuerAuditEntry(
_tenantId,
_issuerId,
action,
timestamp ?? DateTimeOffset.UtcNow,
"test@test.com",
reason,
metadata);
}
}

View File

@@ -0,0 +1,43 @@
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public sealed partial class IssuerAuditSinkTests
{
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsMetadataAsync()
{
var metadata = new Dictionary<string, string>
{
["oldSlug"] = "old-issuer",
["newSlug"] = "new-issuer"
};
var entry = CreateAuditEntry("issuer.slug.changed", "Slug updated", metadata);
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Details.Should().ContainKey("oldSlug");
persisted.Details["oldSlug"].Should().Be("old-issuer");
persisted.Details.Should().ContainKey("newSlug");
persisted.Details["newSlug"].Should().Be("new-issuer");
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsEmptyMetadataAsync()
{
var entry = CreateAuditEntry("issuer.deleted", "Issuer removed");
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Details.Should().NotBeNull();
persisted.Details.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,65 @@
using Npgsql;
using System.Text.Json;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public sealed partial class IssuerAuditSinkTests
{
private async Task<AuditEntryDto?> ReadAuditEntryAsync(string tenantId, string issuerId)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", CancellationToken.None);
const string sql = """
SELECT actor, action, reason, details, occurred_at
FROM issuer.audit
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
ORDER BY occurred_at DESC
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tenantId", Guid.Parse(tenantId));
command.Parameters.AddWithValue("issuerId", Guid.Parse(issuerId));
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
{
return null;
}
var detailsJson = reader.GetString(3);
var details = JsonSerializer.Deserialize<Dictionary<string, string>>(detailsJson) ?? [];
return new AuditEntryDto(
reader.GetString(0),
reader.GetString(1),
reader.IsDBNull(2) ? null : reader.GetString(2),
details,
reader.GetDateTime(4));
}
private async Task<int> CountAuditEntriesAsync(string tenantId, string issuerId)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", CancellationToken.None);
const string sql = """
SELECT COUNT(*)
FROM issuer.audit
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tenantId", Guid.Parse(tenantId));
command.Parameters.AddWithValue("issuerId", Guid.Parse(issuerId));
var result = await command.ExecuteScalarAsync();
return Convert.ToInt32(result);
}
private sealed record AuditEntryDto(
string Actor,
string Action,
string? Reason,
Dictionary<string, string> Details,
DateTime OccurredAt);
}

View File

@@ -0,0 +1,51 @@
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public sealed partial class IssuerAuditSinkTests
{
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsNullReasonAsync()
{
var entry = CreateAuditEntry("issuer.updated", null);
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Reason.Should().BeNull();
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsTimestampCorrectlyAsync()
{
var now = DateTimeOffset.UtcNow;
var entry = CreateAuditEntry("issuer.key.added", "Key added", timestamp: now);
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.OccurredAt.Should().BeCloseTo(now.UtcDateTime, TimeSpan.FromSeconds(1));
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsMultipleEntriesAsync()
{
var entry1 = CreateAuditEntry("issuer.created", "Created");
var entry2 = CreateAuditEntry("issuer.updated", "Updated");
var entry3 = CreateAuditEntry("issuer.key.added", "Key added");
await _auditSink.WriteAsync(entry1, CancellationToken.None);
await _auditSink.WriteAsync(entry2, CancellationToken.None);
await _auditSink.WriteAsync(entry3, CancellationToken.None);
var count = await CountAuditEntriesAsync(_tenantId, _issuerId);
count.Should().Be(3);
}
}

View File

@@ -0,0 +1,44 @@
using FluentAssertions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public sealed partial class IssuerAuditSinkTests
{
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsAuditEntryAsync()
{
var entry = CreateAuditEntry("issuer.created", "Issuer was created");
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Action.Should().Be("issuer.created");
persisted.Reason.Should().Be("Issuer was created");
persisted.Actor.Should().Be("test@test.com");
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsActorCorrectlyAsync()
{
var entry = new IssuerAuditEntry(
_tenantId,
_issuerId,
"issuer.trust.changed",
DateTimeOffset.UtcNow,
"admin@company.com",
"Trust level modified",
null);
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Actor.Should().Be("admin@company.com");
}
}

View File

@@ -1,18 +1,13 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
[Collection(IssuerDirectoryPostgresCollection.Name)]
public sealed class IssuerAuditSinkTests : IAsyncLifetime
public sealed partial class IssuerAuditSinkTests : IAsyncLifetime
{
private readonly IssuerDirectoryPostgresFixture _fixture;
private readonly PostgresIssuerRepository _issuerRepository;
@@ -42,219 +37,4 @@ public sealed class IssuerAuditSinkTests : IAsyncLifetime
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WriteAsync_PersistsAuditEntry()
{
var entry = CreateAuditEntry("issuer.created", "Issuer was created");
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Action.Should().Be("issuer.created");
persisted.Reason.Should().Be("Issuer was created");
persisted.Actor.Should().Be("test@test.com");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WriteAsync_PersistsMetadata()
{
var metadata = new Dictionary<string, string>
{
["oldSlug"] = "old-issuer",
["newSlug"] = "new-issuer"
};
var entry = CreateAuditEntry("issuer.slug.changed", "Slug updated", metadata);
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Details.Should().ContainKey("oldSlug");
persisted.Details["oldSlug"].Should().Be("old-issuer");
persisted.Details.Should().ContainKey("newSlug");
persisted.Details["newSlug"].Should().Be("new-issuer");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WriteAsync_PersistsEmptyMetadata()
{
var entry = CreateAuditEntry("issuer.deleted", "Issuer removed");
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Details.Should().NotBeNull();
persisted.Details.Should().BeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WriteAsync_PersistsNullReason()
{
var entry = CreateAuditEntry("issuer.updated", null);
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Reason.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WriteAsync_PersistsTimestampCorrectly()
{
var now = DateTimeOffset.UtcNow;
var entry = CreateAuditEntry("issuer.key.added", "Key added", timestamp: now);
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.OccurredAt.Should().BeCloseTo(now.UtcDateTime, TimeSpan.FromSeconds(1));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WriteAsync_PersistsMultipleEntriesForSameIssuer()
{
var entry1 = CreateAuditEntry("issuer.created", "Created");
var entry2 = CreateAuditEntry("issuer.updated", "Updated");
var entry3 = CreateAuditEntry("issuer.key.added", "Key added");
await _auditSink.WriteAsync(entry1, CancellationToken.None);
await _auditSink.WriteAsync(entry2, CancellationToken.None);
await _auditSink.WriteAsync(entry3, CancellationToken.None);
var count = await CountAuditEntriesAsync(_tenantId, _issuerId);
count.Should().Be(3);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WriteAsync_PersistsActorCorrectly()
{
var entry = new IssuerAuditEntry(
_tenantId,
_issuerId,
"issuer.trust.changed",
DateTimeOffset.UtcNow,
"admin@company.com",
"Trust level modified",
null);
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Actor.Should().Be("admin@company.com");
}
private async Task<string> SeedIssuerAsync()
{
var issuerId = Guid.NewGuid().ToString();
var now = DateTimeOffset.UtcNow;
var issuer = new IssuerRecord
{
Id = issuerId,
TenantId = _tenantId,
Slug = $"test-issuer-{Guid.NewGuid():N}",
DisplayName = "Test Issuer",
Description = "Test issuer for audit tests",
Contact = new IssuerContact(null, null, null, null),
Metadata = new IssuerMetadata(null, null, null, null, [], new Dictionary<string, string>()),
Endpoints = [],
Tags = [],
IsSystemSeed = false,
CreatedAtUtc = now,
CreatedBy = "test@test.com",
UpdatedAtUtc = now,
UpdatedBy = "test@test.com"
};
await _issuerRepository.UpsertAsync(issuer, CancellationToken.None);
return issuerId;
}
private IssuerAuditEntry CreateAuditEntry(
string action,
string? reason,
IReadOnlyDictionary<string, string>? metadata = null,
DateTimeOffset? timestamp = null)
{
return new IssuerAuditEntry(
_tenantId,
_issuerId,
action,
timestamp ?? DateTimeOffset.UtcNow,
"test@test.com",
reason,
metadata);
}
private async Task<AuditEntryDto?> ReadAuditEntryAsync(string tenantId, string issuerId)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", CancellationToken.None);
const string sql = """
SELECT actor, action, reason, details, occurred_at
FROM issuer.audit
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
ORDER BY occurred_at DESC
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tenantId", Guid.Parse(tenantId));
command.Parameters.AddWithValue("issuerId", Guid.Parse(issuerId));
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
{
return null;
}
var detailsJson = reader.GetString(3);
var details = JsonSerializer.Deserialize<Dictionary<string, string>>(detailsJson) ?? [];
return new AuditEntryDto(
reader.GetString(0),
reader.GetString(1),
reader.IsDBNull(2) ? null : reader.GetString(2),
details,
reader.GetDateTime(4));
}
private async Task<int> CountAuditEntriesAsync(string tenantId, string issuerId)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", CancellationToken.None);
const string sql = """
SELECT COUNT(*)
FROM issuer.audit
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tenantId", Guid.Parse(tenantId));
command.Parameters.AddWithValue("issuerId", Guid.Parse(issuerId));
var result = await command.ExecuteScalarAsync();
return Convert.ToInt32(result);
}
private sealed record AuditEntryDto(
string Actor,
string Action,
string? Reason,
Dictionary<string, string> Details,
DateTime OccurredAt);
}

View File

@@ -0,0 +1,49 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Persistence.Extensions;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public class IssuerDirectoryPersistenceExtensionsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddIssuerDirectoryPersistence_ConfiguresSchema_WhenBlank()
{
var services = new ServiceCollection();
services.AddIssuerDirectoryPersistence(options =>
{
options.ConnectionString = "Host=localhost;Database=issuer;Username=postgres;Password=postgres";
options.SchemaName = "";
});
var descriptor = services.Single(sd => sd.ServiceType == typeof(PostgresOptions));
var options = descriptor.ImplementationInstance as PostgresOptions;
options.Should().NotBeNull();
options!.SchemaName.Should().Be("issuer");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddIssuerDirectoryPersistence_RegistersRepositories()
{
var services = new ServiceCollection();
services.AddIssuerDirectoryPersistence(new PostgresOptions
{
ConnectionString = "Host=localhost;Database=issuer;Username=postgres;Password=postgres",
SchemaName = "issuer"
});
services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IssuerDirectoryDataSource));
services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IIssuerRepository));
services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IIssuerKeyRepository));
services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IIssuerTrustRepository));
services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IIssuerAuditSink));
}
}

View File

@@ -1,7 +1,7 @@
using System.Reflection;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using System.Reflection;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;

View File

@@ -3,9 +3,9 @@ using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
using StellaOps.TestKit;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public class IssuerKeyRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture>
@@ -25,9 +25,9 @@ public class IssuerKeyRepositoryTests : IClassFixture<IssuerDirectoryPostgresFix
new(new IssuerDirectoryDataSource(_fixture.Fixture.CreateOptions(), NullLogger<IssuerDirectoryDataSource>.Instance),
NullLogger<PostgresIssuerKeyRepository>.Instance);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AddKey_And_List_Works()
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task AddKey_And_List_WorksAsync()
{
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
@@ -67,4 +67,49 @@ public class IssuerKeyRepositoryTests : IClassFixture<IssuerDirectoryPostgresFix
var keys = await keyRepo.ListAsync(tenant, issuerId, CancellationToken.None);
keys.Should().ContainSingle(k => k.IssuerId == issuerId);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task GetByFingerprint_ReturnsKeyAsync()
{
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
var issuerRepo = CreateIssuerRepo();
var keyRepo = CreateKeyRepo();
var issuer = IssuerRecord.Create(
id: issuerId,
tenantId: tenant,
displayName: "Vendor Y",
slug: "vendor-y",
description: null,
contact: new IssuerContact(null, null, null, null),
metadata: new IssuerMetadata(null, null, null, null, null, null),
endpoints: null,
tags: null,
timestampUtc: DateTimeOffset.Parse("2026-01-02T01:00:00Z"),
actor: "test",
isSystemSeed: false);
await issuerRepo.UpsertAsync(issuer, CancellationToken.None);
var key = IssuerKeyRecord.Create(
id: Guid.NewGuid().ToString(),
issuerId: issuerId,
tenantId: tenant,
type: IssuerKeyType.Ed25519PublicKey,
material: new IssuerKeyMaterial("pem", "pubkey-2"),
fingerprint: "fp-lookup",
createdAtUtc: DateTimeOffset.Parse("2026-01-02T01:05:00Z"),
createdBy: "test",
expiresAtUtc: null,
replacesKeyId: null);
await keyRepo.UpsertAsync(key, CancellationToken.None);
var fetched = await keyRepo.GetByFingerprintAsync(tenant, issuerId, "fp-lookup", CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Fingerprint.Should().Be("fp-lookup");
fetched.Type.Should().Be(IssuerKeyType.Ed25519PublicKey);
}
}

View File

@@ -3,9 +3,9 @@ using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
using StellaOps.TestKit;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public class IssuerRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture>
@@ -25,9 +25,9 @@ public class IssuerRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixtur
return new PostgresIssuerRepository(dataSource, NullLogger<PostgresIssuerRepository>.Instance);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertAndGet_Works_For_Tenant()
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task UpsertAndGet_Works_For_TenantAsync()
{
var repo = CreateRepository();
var tenant = Guid.NewGuid().ToString();
@@ -56,4 +56,46 @@ public class IssuerRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixtur
fetched.DisplayName.Should().Be("Acme Corp");
fetched.Endpoints.Should().HaveCount(1);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task UpsertAndGet_PersistsMetadataAndTagsAsync()
{
var repo = CreateRepository();
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
var timestamp = DateTimeOffset.Parse("2026-01-02T00:00:00Z");
var record = IssuerRecord.Create(
id: issuerId,
tenantId: tenant,
displayName: "Contoso",
slug: "contoso",
description: "Metadata check",
contact: new IssuerContact("security@contoso.test", "555-0100", new Uri("https://contoso.test"), "UTC"),
metadata: new IssuerMetadata(
cveOrgId: "contoso-org",
csafPublisherId: "contoso-publisher",
securityAdvisoriesUrl: new Uri("https://contoso.test/advisories"),
catalogUrl: null,
supportedLanguages: new[] { "en", "fr" },
attributes: new Dictionary<string, string> { ["tier"] = "gold" }),
endpoints: new[]
{
new IssuerEndpoint("vex", new Uri("https://contoso.test/vex"), "csaf", true)
},
tags: new[] { "vendor", "priority" },
timestampUtc: timestamp,
actor: "test",
isSystemSeed: false);
await repo.UpsertAsync(record, CancellationToken.None);
var fetched = await repo.GetAsync(tenant, issuerId, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Contact.Email.Should().Be("security@contoso.test");
fetched.Metadata.Attributes.Should().ContainKey("tier");
fetched.Tags.Should().Contain("vendor");
fetched.Endpoints.Should().ContainSingle().Which.RequiresAuthentication.Should().BeTrue();
}
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0377-T | DONE | Revalidated 2026-01-07; test coverage audit for IssuerDirectory.Persistence.Tests. |
| AUDIT-0377-A | DONE | Waived (test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-07 | DONE | 2026-02-04: Split audit sink tests into partials, sorted usings, added DI registration unit tests and new coverage (SPRINT_20260130_002). |

View File

@@ -3,9 +3,9 @@ using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
using StellaOps.TestKit;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public class TrustRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture>
@@ -25,9 +25,9 @@ public class TrustRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture
new(new IssuerDirectoryDataSource(_fixture.Fixture.CreateOptions(), NullLogger<IssuerDirectoryDataSource>.Instance),
NullLogger<PostgresIssuerTrustRepository>.Instance);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertTrustOverride_Works()
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task UpsertTrustOverride_WorksAsync()
{
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
@@ -64,4 +64,43 @@ public class TrustRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture
fetched.Should().NotBeNull();
fetched!.Weight.Should().Be(0.75m);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Delete_RemovesTrustOverrideAsync()
{
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
var issuerRepo = CreateIssuerRepo();
var trustRepo = CreateTrustRepo();
var issuer = IssuerRecord.Create(
id: issuerId,
tenantId: tenant,
displayName: "Delete Issuer",
slug: "delete-issuer",
description: null,
contact: new IssuerContact(null, null, null, null),
metadata: new IssuerMetadata(null, null, null, null, null, null),
endpoints: null,
tags: null,
timestampUtc: DateTimeOffset.Parse("2026-01-03T00:00:00Z"),
actor: "test",
isSystemSeed: false);
await issuerRepo.UpsertAsync(issuer, CancellationToken.None);
var trust = IssuerTrustOverrideRecord.Create(
issuerId: issuerId,
tenantId: tenant,
weight: 0.2m,
reason: null,
timestampUtc: DateTimeOffset.Parse("2026-01-03T00:10:00Z"),
actor: "test");
await trustRepo.UpsertAsync(trust, CancellationToken.None);
await trustRepo.DeleteAsync(tenant, issuerId, CancellationToken.None);
var fetched = await trustRepo.GetAsync(tenant, issuerId, CancellationToken.None);
fetched.Should().BeNull();
}
}