part #2
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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*");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
public sealed record IssuerTrustView(
|
||||
IssuerTrustOverrideRecord? TenantOverride,
|
||||
IssuerTrustOverrideRecord? GlobalOverride,
|
||||
decimal EffectiveWeight);
|
||||
@@ -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). |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user