Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
using System.Collections.Concurrent;
|
||||
using FluentAssertions;
|
||||
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
|
||||
{
|
||||
private readonly FakeIssuerRepository _repository = new();
|
||||
private readonly FakeIssuerAuditSink _auditSink = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.Parse("2025-11-01T12:00:00Z"));
|
||||
private readonly IssuerDirectoryService _service;
|
||||
|
||||
public 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(
|
||||
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);
|
||||
|
||||
_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,198 @@
|
||||
using System.Collections.Concurrent;
|
||||
using FluentAssertions;
|
||||
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
|
||||
{
|
||||
private readonly FakeIssuerRepository _issuerRepository = new();
|
||||
private readonly FakeIssuerKeyRepository _keyRepository = new();
|
||||
private readonly FakeIssuerAuditSink _auditSink = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.Parse("2025-11-01T12:00:00Z"));
|
||||
private readonly IssuerKeyService _service;
|
||||
|
||||
public IssuerKeyServiceTests()
|
||||
{
|
||||
_service = new IssuerKeyService(
|
||||
_issuerRepository,
|
||||
_keyRepository,
|
||||
_auditSink,
|
||||
_timeProvider,
|
||||
NullLogger<IssuerKeyService>.Instance);
|
||||
|
||||
var issuer = IssuerRecord.Create(
|
||||
id: "red-hat",
|
||||
tenantId: "tenant-a",
|
||||
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: false);
|
||||
|
||||
_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,153 @@
|
||||
using System.Collections.Concurrent;
|
||||
using FluentAssertions;
|
||||
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
|
||||
{
|
||||
private readonly FakeIssuerRepository _issuerRepository = new();
|
||||
private readonly FakeIssuerTrustRepository _trustRepository = new();
|
||||
private readonly FakeIssuerAuditSink _auditSink = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.Parse("2025-11-01T00:00:00Z"));
|
||||
private readonly IssuerTrustService _service;
|
||||
|
||||
public IssuerTrustServiceTests()
|
||||
{
|
||||
_service = new IssuerTrustService(_issuerRepository, _trustRepository, _auditSink, _timeProvider);
|
||||
|
||||
var issuer = IssuerRecord.Create(
|
||||
id: "issuer-1",
|
||||
tenantId: "tenant-a",
|
||||
displayName: "Issuer",
|
||||
slug: "issuer",
|
||||
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: false);
|
||||
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.IssuerDirectory.Core\StellaOps.IssuerDirectory.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Persists audit events describing issuer changes.
|
||||
/// </summary>
|
||||
public interface IIssuerAuditSink
|
||||
{
|
||||
Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Repository abstraction for issuer key persistence.
|
||||
/// </summary>
|
||||
public interface IIssuerKeyRepository
|
||||
{
|
||||
Task<IssuerKeyRecord?> GetAsync(string tenantId, string issuerId, string keyId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IssuerKeyRecord?> GetByFingerprintAsync(string tenantId, string issuerId, string fingerprint, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(string tenantId, string issuerId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(string issuerId, CancellationToken cancellationToken);
|
||||
|
||||
Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Repository abstraction for issuer directory persistence.
|
||||
/// </summary>
|
||||
public interface IIssuerRepository
|
||||
{
|
||||
Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken);
|
||||
|
||||
Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Repository abstraction for trust weight overrides.
|
||||
/// </summary>
|
||||
public interface IIssuerTrustRepository
|
||||
{
|
||||
Task<IssuerTrustOverrideRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken);
|
||||
|
||||
Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken);
|
||||
|
||||
Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an audit log describing an issuer change.
|
||||
/// </summary>
|
||||
public sealed class IssuerAuditEntry
|
||||
{
|
||||
public IssuerAuditEntry(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
string action,
|
||||
DateTimeOffset timestampUtc,
|
||||
string actor,
|
||||
string? reason,
|
||||
IReadOnlyDictionary<string, string>? metadata)
|
||||
{
|
||||
TenantId = Normalize(tenantId, nameof(tenantId));
|
||||
IssuerId = Normalize(issuerId, nameof(issuerId));
|
||||
Action = Normalize(action, nameof(action));
|
||||
TimestampUtc = timestampUtc.ToUniversalTime();
|
||||
Actor = Normalize(actor, nameof(actor));
|
||||
Reason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
|
||||
Metadata = metadata is null
|
||||
? new Dictionary<string, string>()
|
||||
: new Dictionary<string, string>(metadata, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string IssuerId { get; }
|
||||
|
||||
public string Action { get; }
|
||||
|
||||
public DateTimeOffset TimestampUtc { get; }
|
||||
|
||||
public string Actor { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; }
|
||||
|
||||
private static string Normalize(string value, string argumentName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value is required.", argumentName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Contact information for a publisher or issuer.
|
||||
/// </summary>
|
||||
public sealed class IssuerContact
|
||||
{
|
||||
public IssuerContact(string? email, string? phone, Uri? website, string? timezone)
|
||||
{
|
||||
Email = Normalize(email);
|
||||
Phone = Normalize(phone);
|
||||
Website = website;
|
||||
Timezone = Normalize(timezone);
|
||||
}
|
||||
|
||||
public string? Email { get; }
|
||||
|
||||
public string? Phone { get; }
|
||||
|
||||
public Uri? Website { get; }
|
||||
|
||||
public string? Timezone { get; }
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an endpoint that exposes attestation or CSAF data for an issuer.
|
||||
/// </summary>
|
||||
public sealed class IssuerEndpoint
|
||||
{
|
||||
public IssuerEndpoint(string kind, Uri url, string? format, bool requiresAuthentication)
|
||||
{
|
||||
Kind = Normalize(kind, nameof(kind));
|
||||
Url = url ?? throw new ArgumentNullException(nameof(url));
|
||||
Format = string.IsNullOrWhiteSpace(format) ? null : format.Trim().ToLowerInvariant();
|
||||
RequiresAuthentication = requiresAuthentication;
|
||||
}
|
||||
|
||||
public string Kind { get; }
|
||||
|
||||
public Uri Url { get; }
|
||||
|
||||
public string? Format { get; }
|
||||
|
||||
public bool RequiresAuthentication { get; }
|
||||
|
||||
private static string Normalize(string value, string argumentName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value is required.", argumentName);
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the encoded key material.
|
||||
/// </summary>
|
||||
public sealed record IssuerKeyMaterial(string Format, string Value)
|
||||
{
|
||||
public string Format { get; } = Normalize(Format, nameof(Format));
|
||||
|
||||
public string Value { get; } = Normalize(Value, nameof(Value));
|
||||
|
||||
private static string Normalize(string value, string argumentName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value must be provided.", argumentName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an issuer signing key.
|
||||
/// </summary>
|
||||
public sealed record IssuerKeyRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required string IssuerId { get; init; }
|
||||
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
public required IssuerKeyType Type { get; init; }
|
||||
|
||||
public required IssuerKeyStatus Status { get; init; }
|
||||
|
||||
public required IssuerKeyMaterial Material { get; init; }
|
||||
|
||||
public required string Fingerprint { get; init; }
|
||||
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
|
||||
public required string UpdatedBy { get; init; }
|
||||
|
||||
public DateTimeOffset? ExpiresAtUtc { get; init; }
|
||||
|
||||
public DateTimeOffset? RetiredAtUtc { get; init; }
|
||||
|
||||
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,11 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status for issuer keys.
|
||||
/// </summary>
|
||||
public enum IssuerKeyStatus
|
||||
{
|
||||
Active,
|
||||
Retired,
|
||||
Revoked
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Supported issuer key kinds.
|
||||
/// </summary>
|
||||
public enum IssuerKeyType
|
||||
{
|
||||
Ed25519PublicKey,
|
||||
X509Certificate,
|
||||
DssePublicKey
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Domain metadata describing issuer provenance and publication capabilities.
|
||||
/// </summary>
|
||||
public sealed class IssuerMetadata
|
||||
{
|
||||
private readonly IReadOnlyCollection<string> _languages;
|
||||
|
||||
public IssuerMetadata(
|
||||
string? cveOrgId,
|
||||
string? csafPublisherId,
|
||||
Uri? securityAdvisoriesUrl,
|
||||
Uri? catalogUrl,
|
||||
IEnumerable<string>? supportedLanguages,
|
||||
IDictionary<string, string>? attributes)
|
||||
{
|
||||
CveOrgId = Normalize(cveOrgId);
|
||||
CsafPublisherId = Normalize(csafPublisherId);
|
||||
SecurityAdvisoriesUrl = securityAdvisoriesUrl;
|
||||
CatalogUrl = catalogUrl;
|
||||
_languages = BuildLanguages(supportedLanguages);
|
||||
Attributes = attributes is null
|
||||
? new ReadOnlyDictionary<string, string>(new Dictionary<string, string>())
|
||||
: new ReadOnlyDictionary<string, string>(
|
||||
attributes.ToDictionary(
|
||||
pair => pair.Key.Trim(),
|
||||
pair => pair.Value.Trim()));
|
||||
}
|
||||
|
||||
public string? CveOrgId { get; }
|
||||
|
||||
public string? CsafPublisherId { get; }
|
||||
|
||||
public Uri? SecurityAdvisoriesUrl { get; }
|
||||
|
||||
public Uri? CatalogUrl { get; }
|
||||
|
||||
public IReadOnlyCollection<string> SupportedLanguages => _languages;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes { get; }
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> BuildLanguages(IEnumerable<string>? languages)
|
||||
{
|
||||
var normalized = languages?
|
||||
.Select(language => language?.Trim())
|
||||
.Where(language => !string.IsNullOrWhiteSpace(language))
|
||||
.Select(language => language!.ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
|
||||
return new ReadOnlyCollection<string>(normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a VEX issuer or CSAF publisher entry managed by the Issuer Directory.
|
||||
/// </summary>
|
||||
public sealed record IssuerRecord
|
||||
{
|
||||
private static readonly StringComparer TagComparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
public required string Slug { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
public required IssuerContact Contact { get; init; }
|
||||
|
||||
public required IssuerMetadata Metadata { get; init; }
|
||||
|
||||
public IReadOnlyCollection<IssuerEndpoint> Endpoints { get; init; } = Array.Empty<IssuerEndpoint>();
|
||||
|
||||
public IReadOnlyCollection<string> Tags { get; init; } = Array.Empty<string>();
|
||||
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known tenant identifiers for issuer directory entries.
|
||||
/// </summary>
|
||||
public static class IssuerTenants
|
||||
{
|
||||
/// <summary>
|
||||
/// Global issuer used for system-wide CSAF publishers available to all tenants.
|
||||
/// </summary>
|
||||
public const string Global = "@global";
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a tenant-specific trust weight override for an issuer.
|
||||
/// </summary>
|
||||
public sealed record IssuerTrustOverrideRecord
|
||||
{
|
||||
public required string IssuerId { get; init; }
|
||||
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
public required decimal Weight { get; init; }
|
||||
|
||||
public string? Reason { get; init; }
|
||||
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
|
||||
public required string UpdatedBy { get; init; }
|
||||
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
public static IssuerTrustOverrideRecord Create(
|
||||
string issuerId,
|
||||
string tenantId,
|
||||
decimal weight,
|
||||
string? reason,
|
||||
DateTimeOffset timestampUtc,
|
||||
string actor)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
|
||||
ValidateWeight(weight);
|
||||
|
||||
return new IssuerTrustOverrideRecord
|
||||
{
|
||||
IssuerId = issuerId.Trim(),
|
||||
TenantId = tenantId.Trim(),
|
||||
Weight = weight,
|
||||
Reason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(),
|
||||
CreatedAtUtc = timestampUtc,
|
||||
CreatedBy = actor.Trim(),
|
||||
UpdatedAtUtc = timestampUtc,
|
||||
UpdatedBy = actor.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
public IssuerTrustOverrideRecord WithUpdated(decimal weight, string? reason, DateTimeOffset timestampUtc, string actor)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
ValidateWeight(weight);
|
||||
|
||||
return this with
|
||||
{
|
||||
Weight = weight,
|
||||
Reason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(),
|
||||
UpdatedAtUtc = timestampUtc,
|
||||
UpdatedBy = actor.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
public static void ValidateWeight(decimal weight)
|
||||
{
|
||||
if (weight is < -10m or > 10m)
|
||||
{
|
||||
throw new InvalidOperationException("Trust weight must be between -10 and 10 inclusive.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Observability;
|
||||
|
||||
internal static class IssuerDirectoryMetrics
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.IssuerDirectory", "1.0");
|
||||
|
||||
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>(
|
||||
"issuer_directory_key_operations_total",
|
||||
description: "Counts issuer key create/rotate/revoke operations.");
|
||||
|
||||
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(1, new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTag(tenantId) },
|
||||
{ "issuer", NormalizeTag(issuerId) },
|
||||
{ "action", action }
|
||||
});
|
||||
}
|
||||
|
||||
public static void RecordKeyOperation(string tenantId, string issuerId, string operation, string keyType)
|
||||
{
|
||||
KeyOperationCounter.Add(1, new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTag(tenantId) },
|
||||
{ "issuer", NormalizeTag(issuerId) },
|
||||
{ "operation", operation },
|
||||
{ "key_type", keyType }
|
||||
});
|
||||
}
|
||||
|
||||
public static void RecordKeyValidationFailure(string tenantId, string issuerId, string reason)
|
||||
{
|
||||
KeyValidationFailureCounter.Add(1, new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTag(tenantId) },
|
||||
{ "issuer", NormalizeTag(issuerId) },
|
||||
{ "reason", reason }
|
||||
});
|
||||
}
|
||||
|
||||
private static string NormalizeTag(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? "unknown" : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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
|
||||
{
|
||||
private readonly IIssuerRepository _repository;
|
||||
private readonly IIssuerAuditSink _auditSink;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<IssuerDirectoryService> _logger;
|
||||
|
||||
public IssuerDirectoryService(
|
||||
IIssuerRepository repository,
|
||||
IIssuerAuditSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<IssuerDirectoryService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
_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,322 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Observability;
|
||||
using StellaOps.IssuerDirectory.Core.Validation;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages issuer signing keys.
|
||||
/// </summary>
|
||||
public sealed class IssuerKeyService
|
||||
{
|
||||
private readonly IIssuerRepository _issuerRepository;
|
||||
private readonly IIssuerKeyRepository _keyRepository;
|
||||
private readonly IIssuerAuditSink _auditSink;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<IssuerKeyService> _logger;
|
||||
|
||||
public IssuerKeyService(
|
||||
IIssuerRepository issuerRepository,
|
||||
IIssuerKeyRepository keyRepository,
|
||||
IIssuerAuditSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<IssuerKeyService> logger)
|
||||
{
|
||||
_issuerRepository = issuerRepository ?? throw new ArgumentNullException(nameof(issuerRepository));
|
||||
_keyRepository = keyRepository ?? throw new ArgumentNullException(nameof(keyRepository));
|
||||
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
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(
|
||||
Guid.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(
|
||||
Guid.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,137 @@
|
||||
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
|
||||
{
|
||||
private readonly IIssuerRepository _issuerRepository;
|
||||
private readonly IIssuerTrustRepository _trustRepository;
|
||||
private readonly IIssuerAuditSink _auditSink;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public IssuerTrustService(
|
||||
IIssuerRepository issuerRepository,
|
||||
IIssuerTrustRepository trustRepository,
|
||||
IIssuerAuditSink auditSink,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_issuerRepository = issuerRepository ?? throw new ArgumentNullException(nameof(issuerRepository));
|
||||
_trustRepository = trustRepository ?? throw new ArgumentNullException(nameof(trustRepository));
|
||||
_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,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,25 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Result of validating an issuer key request.
|
||||
/// </summary>
|
||||
public sealed class IssuerKeyValidationResult
|
||||
{
|
||||
public IssuerKeyValidationResult(
|
||||
IssuerKeyMaterial material,
|
||||
byte[] rawKeyBytes,
|
||||
DateTimeOffset? expiresAtUtc)
|
||||
{
|
||||
Material = material ?? throw new ArgumentNullException(nameof(material));
|
||||
RawKeyBytes = rawKeyBytes ?? throw new ArgumentNullException(nameof(rawKeyBytes));
|
||||
ExpiresAtUtc = expiresAtUtc?.ToUniversalTime();
|
||||
}
|
||||
|
||||
public IssuerKeyMaterial Material { get; }
|
||||
|
||||
public byte[] RawKeyBytes { get; }
|
||||
|
||||
public DateTimeOffset? ExpiresAtUtc { get; }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Core.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Performs validation and normalization of issuer key material.
|
||||
/// </summary>
|
||||
public static class IssuerKeyValidator
|
||||
{
|
||||
public static IssuerKeyValidationResult Validate(
|
||||
IssuerKeyType type,
|
||||
IssuerKeyMaterial material,
|
||||
DateTimeOffset? expiresAtUtc,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
if (material is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(material));
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
var normalizedMaterial = NormalizeMaterial(material);
|
||||
var rawKey = type switch
|
||||
{
|
||||
IssuerKeyType.Ed25519PublicKey => ValidateEd25519(normalizedMaterial),
|
||||
IssuerKeyType.X509Certificate => ValidateCertificate(normalizedMaterial),
|
||||
IssuerKeyType.DssePublicKey => ValidateDsseKey(normalizedMaterial),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unsupported issuer key type.")
|
||||
};
|
||||
|
||||
if (expiresAtUtc is { } expiry)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (expiry.ToUniversalTime() <= now)
|
||||
{
|
||||
throw new InvalidOperationException("Key expiry must be in the future.");
|
||||
}
|
||||
}
|
||||
|
||||
return new IssuerKeyValidationResult(normalizedMaterial, rawKey, expiresAtUtc);
|
||||
}
|
||||
|
||||
private static IssuerKeyMaterial NormalizeMaterial(IssuerKeyMaterial material)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Documents;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Internal;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.Audit;
|
||||
|
||||
public sealed class MongoIssuerAuditSink : IIssuerAuditSink
|
||||
{
|
||||
private readonly IssuerDirectoryMongoContext _context;
|
||||
|
||||
public MongoIssuerAuditSink(IssuerDirectoryMongoContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
var document = new IssuerAuditDocument
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
TenantId = entry.TenantId,
|
||||
IssuerId = entry.IssuerId,
|
||||
Action = entry.Action,
|
||||
TimestampUtc = entry.TimestampUtc,
|
||||
Actor = entry.Actor,
|
||||
Reason = entry.Reason,
|
||||
Metadata = new Dictionary<string, string>(entry.Metadata, StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
|
||||
await _context.Audits.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.Documents;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class IssuerAuditDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[BsonElement("tenant_id")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("issuer_id")]
|
||||
public string IssuerId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("action")]
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("timestamp")]
|
||||
public DateTimeOffset TimestampUtc { get; set; }
|
||||
|
||||
[BsonElement("actor")]
|
||||
public string Actor { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("reason")]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
[BsonElement("metadata")]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.Documents;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class IssuerDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("tenant_id")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("display_name")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[BsonElement("contact")]
|
||||
public IssuerContactDocument Contact { get; set; } = new();
|
||||
|
||||
[BsonElement("metadata")]
|
||||
public IssuerMetadataDocument Metadata { get; set; } = new();
|
||||
|
||||
[BsonElement("endpoints")]
|
||||
public List<IssuerEndpointDocument> Endpoints { get; set; } = new();
|
||||
|
||||
[BsonElement("tags")]
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
[BsonElement("created_at")]
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
[BsonElement("created_by")]
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("updated_at")]
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
|
||||
[BsonElement("updated_by")]
|
||||
public string UpdatedBy { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("is_seed")]
|
||||
public bool IsSystemSeed { get; set; }
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class IssuerContactDocument
|
||||
{
|
||||
[BsonElement("email")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[BsonElement("phone")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[BsonElement("website")]
|
||||
public string? Website { get; set; }
|
||||
|
||||
[BsonElement("timezone")]
|
||||
public string? Timezone { get; set; }
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class IssuerMetadataDocument
|
||||
{
|
||||
[BsonElement("cve_org_id")]
|
||||
public string? CveOrgId { get; set; }
|
||||
|
||||
[BsonElement("csaf_publisher_id")]
|
||||
public string? CsafPublisherId { get; set; }
|
||||
|
||||
[BsonElement("security_advisories_url")]
|
||||
public string? SecurityAdvisoriesUrl { get; set; }
|
||||
|
||||
[BsonElement("catalog_url")]
|
||||
public string? CatalogUrl { get; set; }
|
||||
|
||||
[BsonElement("languages")]
|
||||
public List<string> Languages { get; set; } = new();
|
||||
|
||||
[BsonElement("attributes")]
|
||||
public Dictionary<string, string> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class IssuerEndpointDocument
|
||||
{
|
||||
[BsonElement("kind")]
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("format")]
|
||||
public string? Format { get; set; }
|
||||
|
||||
[BsonElement("requires_auth")]
|
||||
public bool RequiresAuthentication { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.Documents;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class IssuerKeyDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("issuer_id")]
|
||||
public string IssuerId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("tenant_id")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("material_format")]
|
||||
public string MaterialFormat { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("material_value")]
|
||||
public string MaterialValue { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("fingerprint")]
|
||||
public string Fingerprint { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("created_at")]
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
[BsonElement("created_by")]
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("updated_at")]
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
|
||||
[BsonElement("updated_by")]
|
||||
public string UpdatedBy { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("expires_at")]
|
||||
public DateTimeOffset? ExpiresAtUtc { get; set; }
|
||||
|
||||
[BsonElement("retired_at")]
|
||||
public DateTimeOffset? RetiredAtUtc { get; set; }
|
||||
|
||||
[BsonElement("revoked_at")]
|
||||
public DateTimeOffset? RevokedAtUtc { get; set; }
|
||||
|
||||
[BsonElement("replaces_key_id")]
|
||||
public string? ReplacesKeyId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.Documents;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class IssuerTrustDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("issuer_id")]
|
||||
public string IssuerId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("tenant_id")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("weight")]
|
||||
public decimal Weight { get; set; }
|
||||
|
||||
[BsonElement("reason")]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
[BsonElement("created_at")]
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
[BsonElement("created_by")]
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("updated_at")]
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
|
||||
[BsonElement("updated_by")]
|
||||
public string UpdatedBy { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Documents;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Options;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB context for Issuer Directory persistence.
|
||||
/// </summary>
|
||||
public sealed class IssuerDirectoryMongoContext
|
||||
{
|
||||
public IssuerDirectoryMongoContext(
|
||||
IOptions<IssuerDirectoryMongoOptions> options,
|
||||
ILogger<IssuerDirectoryMongoContext> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var value = options.Value ?? throw new InvalidOperationException("Mongo options must be provided.");
|
||||
value.Validate();
|
||||
|
||||
var mongoUrl = new MongoUrl(value.ConnectionString);
|
||||
var settings = MongoClientSettings.FromUrl(mongoUrl);
|
||||
if (mongoUrl.UseTls is true && settings.SslSettings is not null)
|
||||
{
|
||||
settings.SslSettings.CheckCertificateRevocation = true;
|
||||
}
|
||||
|
||||
var client = new MongoClient(settings);
|
||||
var database = client.GetDatabase(value.Database);
|
||||
|
||||
logger.LogDebug("IssuerDirectory Mongo connected to {Database}", value.Database);
|
||||
|
||||
Issuers = database.GetCollection<IssuerDocument>(value.IssuersCollection);
|
||||
IssuerKeys = database.GetCollection<IssuerKeyDocument>(value.IssuerKeysCollection);
|
||||
IssuerTrustOverrides = database.GetCollection<IssuerTrustDocument>(value.IssuerTrustCollection);
|
||||
Audits = database.GetCollection<IssuerAuditDocument>(value.AuditCollection);
|
||||
|
||||
EnsureIndexes().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public IMongoCollection<IssuerDocument> Issuers { get; }
|
||||
|
||||
public IMongoCollection<IssuerKeyDocument> IssuerKeys { get; }
|
||||
|
||||
public IMongoCollection<IssuerTrustDocument> IssuerTrustOverrides { get; }
|
||||
|
||||
public IMongoCollection<IssuerAuditDocument> Audits { get; }
|
||||
|
||||
private async Task EnsureIndexes()
|
||||
{
|
||||
var tenantSlugIndex = new CreateIndexModel<IssuerDocument>(
|
||||
Builders<IssuerDocument>.IndexKeys
|
||||
.Ascending(document => document.TenantId)
|
||||
.Ascending(document => document.Slug),
|
||||
new CreateIndexOptions<IssuerDocument>
|
||||
{
|
||||
Name = "tenant_slug_unique",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
await Issuers.Indexes.CreateOneAsync(tenantSlugIndex).ConfigureAwait(false);
|
||||
|
||||
var keyIndex = new CreateIndexModel<IssuerKeyDocument>(
|
||||
Builders<IssuerKeyDocument>.IndexKeys
|
||||
.Ascending(document => document.TenantId)
|
||||
.Ascending(document => document.IssuerId)
|
||||
.Ascending(document => document.Id),
|
||||
new CreateIndexOptions<IssuerKeyDocument>
|
||||
{
|
||||
Name = "issuer_keys_unique",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
var fingerprintIndex = new CreateIndexModel<IssuerKeyDocument>(
|
||||
Builders<IssuerKeyDocument>.IndexKeys
|
||||
.Ascending(document => document.TenantId)
|
||||
.Ascending(document => document.IssuerId)
|
||||
.Ascending(document => document.Fingerprint),
|
||||
new CreateIndexOptions<IssuerKeyDocument>
|
||||
{
|
||||
Name = "issuer_keys_fingerprint",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
await IssuerKeys.Indexes.CreateOneAsync(keyIndex).ConfigureAwait(false);
|
||||
await IssuerKeys.Indexes.CreateOneAsync(fingerprintIndex).ConfigureAwait(false);
|
||||
|
||||
var trustIndex = new CreateIndexModel<IssuerTrustDocument>(
|
||||
Builders<IssuerTrustDocument>.IndexKeys
|
||||
.Ascending(document => document.TenantId)
|
||||
.Ascending(document => document.IssuerId),
|
||||
new CreateIndexOptions<IssuerTrustDocument>
|
||||
{
|
||||
Name = "issuer_trust_unique",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
await IssuerTrustOverrides.Indexes.CreateOneAsync(trustIndex).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Mongo persistence configuration for the Issuer Directory service.
|
||||
/// </summary>
|
||||
public sealed class IssuerDirectoryMongoOptions
|
||||
{
|
||||
public const string SectionName = "IssuerDirectory:Mongo";
|
||||
|
||||
public string ConnectionString { get; set; } = "mongodb://localhost:27017";
|
||||
|
||||
public string Database { get; set; } = "issuer-directory";
|
||||
|
||||
public string IssuersCollection { get; set; } = "issuers";
|
||||
|
||||
public string IssuerKeysCollection { get; set; } = "issuer_keys";
|
||||
|
||||
public string IssuerTrustCollection { get; set; } = "issuer_trust_overrides";
|
||||
|
||||
public string AuditCollection { get; set; } = "issuer_audit";
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory Mongo connection string must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Database))
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory Mongo database must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(IssuersCollection))
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory Mongo issuers collection must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(IssuerKeysCollection))
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory Mongo issuer keys collection must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(IssuerTrustCollection))
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory Mongo issuer trust collection must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(AuditCollection))
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory Mongo audit collection must be configured.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Documents;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Internal;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.Repositories;
|
||||
|
||||
public sealed class MongoIssuerKeyRepository : IIssuerKeyRepository
|
||||
{
|
||||
private readonly IssuerDirectoryMongoContext _context;
|
||||
|
||||
public MongoIssuerKeyRepository(IssuerDirectoryMongoContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<IssuerKeyRecord?> GetAsync(string tenantId, string issuerId, string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<IssuerKeyDocument>.Filter.And(
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.TenantId, tenantId),
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.IssuerId, issuerId),
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.Id, keyId));
|
||||
|
||||
var document = await _context.IssuerKeys.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : MapToDomain(document);
|
||||
}
|
||||
|
||||
public async Task<IssuerKeyRecord?> GetByFingerprintAsync(string tenantId, string issuerId, string fingerprint, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<IssuerKeyDocument>.Filter.And(
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.TenantId, tenantId),
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.IssuerId, issuerId),
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.Fingerprint, fingerprint));
|
||||
|
||||
var document = await _context.IssuerKeys.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : MapToDomain(document);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<IssuerKeyDocument>.Filter.And(
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.TenantId, tenantId),
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.IssuerId, issuerId));
|
||||
|
||||
var documents = await _context.IssuerKeys
|
||||
.Find(filter)
|
||||
.SortBy(document => document.CreatedAtUtc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents.Select(MapToDomain).ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<IssuerKeyDocument>.Filter.And(
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.TenantId, IssuerTenants.Global),
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.IssuerId, issuerId));
|
||||
|
||||
var documents = await _context.IssuerKeys
|
||||
.Find(filter)
|
||||
.SortBy(document => document.CreatedAtUtc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents.Select(MapToDomain).ToArray();
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var document = MapToDocument(record);
|
||||
var filter = Builders<IssuerKeyDocument>.Filter.And(
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.TenantId, record.TenantId),
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.IssuerId, record.IssuerId),
|
||||
Builders<IssuerKeyDocument>.Filter.Eq(doc => doc.Id, record.Id));
|
||||
|
||||
await _context.IssuerKeys.ReplaceOneAsync(
|
||||
filter,
|
||||
document,
|
||||
new ReplaceOptions { IsUpsert = true },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IssuerKeyRecord MapToDomain(IssuerKeyDocument document)
|
||||
{
|
||||
return new IssuerKeyRecord
|
||||
{
|
||||
Id = document.Id,
|
||||
IssuerId = document.IssuerId,
|
||||
TenantId = document.TenantId,
|
||||
Type = Enum.Parse<IssuerKeyType>(document.Type, ignoreCase: true),
|
||||
Status = Enum.Parse<IssuerKeyStatus>(document.Status, ignoreCase: true),
|
||||
Material = new IssuerKeyMaterial(document.MaterialFormat, document.MaterialValue),
|
||||
Fingerprint = document.Fingerprint,
|
||||
CreatedAtUtc = document.CreatedAtUtc,
|
||||
CreatedBy = document.CreatedBy,
|
||||
UpdatedAtUtc = document.UpdatedAtUtc,
|
||||
UpdatedBy = document.UpdatedBy,
|
||||
ExpiresAtUtc = document.ExpiresAtUtc,
|
||||
RetiredAtUtc = document.RetiredAtUtc,
|
||||
RevokedAtUtc = document.RevokedAtUtc,
|
||||
ReplacesKeyId = document.ReplacesKeyId
|
||||
};
|
||||
}
|
||||
|
||||
private static IssuerKeyDocument MapToDocument(IssuerKeyRecord record)
|
||||
{
|
||||
return new IssuerKeyDocument
|
||||
{
|
||||
Id = record.Id,
|
||||
IssuerId = record.IssuerId,
|
||||
TenantId = record.TenantId,
|
||||
Type = record.Type.ToString(),
|
||||
Status = record.Status.ToString(),
|
||||
MaterialFormat = record.Material.Format,
|
||||
MaterialValue = record.Material.Value,
|
||||
Fingerprint = record.Fingerprint,
|
||||
CreatedAtUtc = record.CreatedAtUtc,
|
||||
CreatedBy = record.CreatedBy,
|
||||
UpdatedAtUtc = record.UpdatedAtUtc,
|
||||
UpdatedBy = record.UpdatedBy,
|
||||
ExpiresAtUtc = record.ExpiresAtUtc,
|
||||
RetiredAtUtc = record.RetiredAtUtc,
|
||||
RevokedAtUtc = record.RevokedAtUtc,
|
||||
ReplacesKeyId = record.ReplacesKeyId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Documents;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Internal;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.Repositories;
|
||||
|
||||
public sealed class MongoIssuerRepository : IIssuerRepository
|
||||
{
|
||||
private readonly IssuerDirectoryMongoContext _context;
|
||||
|
||||
public MongoIssuerRepository(IssuerDirectoryMongoContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<IssuerDocument>.Filter.And(
|
||||
Builders<IssuerDocument>.Filter.Eq(doc => doc.TenantId, tenantId),
|
||||
Builders<IssuerDocument>.Filter.Eq(doc => doc.Id, issuerId));
|
||||
|
||||
var cursor = await _context.Issuers
|
||||
.Find(filter)
|
||||
.Limit(1)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return cursor is null ? null : MapToDomain(cursor);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<IssuerDocument>.Filter.Eq(doc => doc.TenantId, tenantId);
|
||||
var documents = await _context.Issuers.Find(filter)
|
||||
.SortBy(doc => doc.Slug)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents.Select(MapToDomain).ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var documents = await _context.Issuers
|
||||
.Find(doc => doc.TenantId == IssuerTenants.Global)
|
||||
.SortBy(doc => doc.Slug)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents.Select(MapToDomain).ToArray();
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var document = MapToDocument(record);
|
||||
var filter = Builders<IssuerDocument>.Filter.And(
|
||||
Builders<IssuerDocument>.Filter.Eq(doc => doc.TenantId, record.TenantId),
|
||||
Builders<IssuerDocument>.Filter.Eq(doc => doc.Id, record.Id));
|
||||
|
||||
await _context.Issuers
|
||||
.ReplaceOneAsync(
|
||||
filter,
|
||||
document,
|
||||
new ReplaceOptions { IsUpsert = true },
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<IssuerDocument>.Filter.And(
|
||||
Builders<IssuerDocument>.Filter.Eq(doc => doc.TenantId, tenantId),
|
||||
Builders<IssuerDocument>.Filter.Eq(doc => doc.Id, issuerId));
|
||||
|
||||
await _context.Issuers.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IssuerRecord MapToDomain(IssuerDocument document)
|
||||
{
|
||||
var contact = new IssuerContact(
|
||||
document.Contact.Email,
|
||||
document.Contact.Phone,
|
||||
string.IsNullOrWhiteSpace(document.Contact.Website) ? null : new Uri(document.Contact.Website),
|
||||
document.Contact.Timezone);
|
||||
|
||||
var metadata = new IssuerMetadata(
|
||||
document.Metadata.CveOrgId,
|
||||
document.Metadata.CsafPublisherId,
|
||||
string.IsNullOrWhiteSpace(document.Metadata.SecurityAdvisoriesUrl)
|
||||
? null
|
||||
: new Uri(document.Metadata.SecurityAdvisoriesUrl),
|
||||
string.IsNullOrWhiteSpace(document.Metadata.CatalogUrl)
|
||||
? null
|
||||
: new Uri(document.Metadata.CatalogUrl),
|
||||
document.Metadata.Languages,
|
||||
document.Metadata.Attributes);
|
||||
|
||||
var endpoints = document.Endpoints
|
||||
.Select(endpoint => new IssuerEndpoint(
|
||||
endpoint.Kind,
|
||||
new Uri(endpoint.Url),
|
||||
endpoint.Format,
|
||||
endpoint.RequiresAuthentication))
|
||||
.ToArray();
|
||||
|
||||
return new IssuerRecord
|
||||
{
|
||||
Id = document.Id,
|
||||
TenantId = document.TenantId,
|
||||
DisplayName = document.DisplayName,
|
||||
Slug = document.Slug,
|
||||
Description = document.Description,
|
||||
Contact = contact,
|
||||
Metadata = metadata,
|
||||
Endpoints = endpoints,
|
||||
Tags = document.Tags,
|
||||
CreatedAtUtc = document.CreatedAtUtc,
|
||||
CreatedBy = document.CreatedBy,
|
||||
UpdatedAtUtc = document.UpdatedAtUtc,
|
||||
UpdatedBy = document.UpdatedBy,
|
||||
IsSystemSeed = document.IsSystemSeed
|
||||
};
|
||||
}
|
||||
|
||||
private static IssuerDocument MapToDocument(IssuerRecord record)
|
||||
{
|
||||
var contact = new IssuerContactDocument
|
||||
{
|
||||
Email = record.Contact.Email,
|
||||
Phone = record.Contact.Phone,
|
||||
Website = record.Contact.Website?.ToString(),
|
||||
Timezone = record.Contact.Timezone
|
||||
};
|
||||
|
||||
var metadataDocument = new IssuerMetadataDocument
|
||||
{
|
||||
CveOrgId = record.Metadata.CveOrgId,
|
||||
CsafPublisherId = record.Metadata.CsafPublisherId,
|
||||
SecurityAdvisoriesUrl = record.Metadata.SecurityAdvisoriesUrl?.ToString(),
|
||||
CatalogUrl = record.Metadata.CatalogUrl?.ToString(),
|
||||
Languages = record.Metadata.SupportedLanguages.ToList(),
|
||||
Attributes = new Dictionary<string, string>(record.Metadata.Attributes, StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
|
||||
var endpoints = record.Endpoints
|
||||
.Select(endpoint => new IssuerEndpointDocument
|
||||
{
|
||||
Kind = endpoint.Kind,
|
||||
Url = endpoint.Url.ToString(),
|
||||
Format = endpoint.Format,
|
||||
RequiresAuthentication = endpoint.RequiresAuthentication
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new IssuerDocument
|
||||
{
|
||||
Id = record.Id,
|
||||
TenantId = record.TenantId,
|
||||
DisplayName = record.DisplayName,
|
||||
Slug = record.Slug,
|
||||
Description = record.Description,
|
||||
Contact = contact,
|
||||
Metadata = metadataDocument,
|
||||
Endpoints = endpoints,
|
||||
Tags = record.Tags.ToList(),
|
||||
CreatedAtUtc = record.CreatedAtUtc,
|
||||
CreatedBy = record.CreatedBy,
|
||||
UpdatedAtUtc = record.UpdatedAtUtc,
|
||||
UpdatedBy = record.UpdatedBy,
|
||||
IsSystemSeed = record.IsSystemSeed
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Globalization;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Documents;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Internal;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.Repositories;
|
||||
|
||||
public sealed class MongoIssuerTrustRepository : IIssuerTrustRepository
|
||||
{
|
||||
private readonly IssuerDirectoryMongoContext _context;
|
||||
|
||||
public MongoIssuerTrustRepository(IssuerDirectoryMongoContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<IssuerTrustOverrideRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<IssuerTrustDocument>.Filter.And(
|
||||
Builders<IssuerTrustDocument>.Filter.Eq(doc => doc.TenantId, tenantId),
|
||||
Builders<IssuerTrustDocument>.Filter.Eq(doc => doc.IssuerId, issuerId));
|
||||
|
||||
var document = await _context.IssuerTrustOverrides
|
||||
.Find(filter)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return document is null ? null : MapToDomain(document);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var document = MapToDocument(record);
|
||||
var filter = Builders<IssuerTrustDocument>.Filter.And(
|
||||
Builders<IssuerTrustDocument>.Filter.Eq(doc => doc.TenantId, record.TenantId),
|
||||
Builders<IssuerTrustDocument>.Filter.Eq(doc => doc.IssuerId, record.IssuerId));
|
||||
|
||||
await _context.IssuerTrustOverrides.ReplaceOneAsync(
|
||||
filter,
|
||||
document,
|
||||
new ReplaceOptions { IsUpsert = true },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<IssuerTrustDocument>.Filter.And(
|
||||
Builders<IssuerTrustDocument>.Filter.Eq(doc => doc.TenantId, tenantId),
|
||||
Builders<IssuerTrustDocument>.Filter.Eq(doc => doc.IssuerId, issuerId));
|
||||
|
||||
await _context.IssuerTrustOverrides.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IssuerTrustOverrideRecord MapToDomain(IssuerTrustDocument document)
|
||||
{
|
||||
return new IssuerTrustOverrideRecord
|
||||
{
|
||||
IssuerId = document.IssuerId,
|
||||
TenantId = document.TenantId,
|
||||
Weight = document.Weight,
|
||||
Reason = document.Reason,
|
||||
CreatedAtUtc = document.CreatedAtUtc,
|
||||
CreatedBy = document.CreatedBy,
|
||||
UpdatedAtUtc = document.UpdatedAtUtc,
|
||||
UpdatedBy = document.UpdatedBy
|
||||
};
|
||||
}
|
||||
|
||||
private static IssuerTrustDocument MapToDocument(IssuerTrustOverrideRecord record)
|
||||
{
|
||||
return new IssuerTrustDocument
|
||||
{
|
||||
Id = string.Create(CultureInfo.InvariantCulture, $"{record.TenantId}:{record.IssuerId}"),
|
||||
IssuerId = record.IssuerId,
|
||||
TenantId = record.TenantId,
|
||||
Weight = record.Weight,
|
||||
Reason = record.Reason,
|
||||
CreatedAtUtc = record.CreatedAtUtc,
|
||||
CreatedBy = record.CreatedBy,
|
||||
UpdatedAtUtc = record.UpdatedAtUtc,
|
||||
UpdatedBy = record.UpdatedBy
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure.Seed;
|
||||
|
||||
/// <summary>
|
||||
/// Loads CSAF publisher metadata into IssuerRecord instances for bootstrap seeding.
|
||||
/// </summary>
|
||||
public sealed class CsafPublisherSeedLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public CsafPublisherSeedLoader(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<IssuerRecord> Load(Stream stream, string actor)
|
||||
{
|
||||
if (stream is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(stream));
|
||||
}
|
||||
|
||||
if (!stream.CanRead)
|
||||
{
|
||||
throw new ArgumentException("Seed stream must be readable.", nameof(stream));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
throw new ArgumentException("Seed actor is required.", nameof(actor));
|
||||
}
|
||||
|
||||
var seeds = JsonSerializer.Deserialize<List<CsafPublisherSeed>>(stream, SerializerOptions)
|
||||
?? throw new InvalidOperationException("CSAF seed data could not be parsed.");
|
||||
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
return seeds.Select(seed => seed.ToIssuerRecord(timestamp, actor)).ToArray();
|
||||
}
|
||||
|
||||
private sealed class CsafPublisherSeed
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string TenantId { get; set; } = IssuerTenants.Global;
|
||||
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public SeedContact Contact { get; set; } = new();
|
||||
|
||||
public SeedMetadata Metadata { get; set; } = new();
|
||||
|
||||
public List<SeedEndpoint> Endpoints { get; set; } = new();
|
||||
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
public IssuerRecord ToIssuerRecord(DateTimeOffset timestamp, string actor)
|
||||
{
|
||||
var contact = new IssuerContact(
|
||||
Contact.Email,
|
||||
Contact.Phone,
|
||||
string.IsNullOrWhiteSpace(Contact.Website) ? null : new Uri(Contact.Website),
|
||||
Contact.Timezone);
|
||||
|
||||
var metadata = new IssuerMetadata(
|
||||
Metadata.CveOrgId,
|
||||
Metadata.CsafPublisherId,
|
||||
string.IsNullOrWhiteSpace(Metadata.SecurityAdvisoriesUrl)
|
||||
? null
|
||||
: new Uri(Metadata.SecurityAdvisoriesUrl),
|
||||
string.IsNullOrWhiteSpace(Metadata.CatalogUrl)
|
||||
? null
|
||||
: new Uri(Metadata.CatalogUrl),
|
||||
Metadata.Languages,
|
||||
Metadata.Attributes);
|
||||
|
||||
var endpoints = Endpoints.Select(endpoint => new IssuerEndpoint(
|
||||
endpoint.Kind,
|
||||
new Uri(endpoint.Url),
|
||||
endpoint.Format,
|
||||
endpoint.RequiresAuth)).ToArray();
|
||||
|
||||
return IssuerRecord.Create(
|
||||
string.IsNullOrWhiteSpace(Id) ? Slug : Id,
|
||||
string.IsNullOrWhiteSpace(TenantId) ? IssuerTenants.Global : TenantId,
|
||||
DisplayName,
|
||||
string.IsNullOrWhiteSpace(Slug) ? Id : Slug,
|
||||
Description,
|
||||
contact,
|
||||
metadata,
|
||||
endpoints,
|
||||
Tags,
|
||||
timestamp,
|
||||
actor,
|
||||
isSystemSeed: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SeedContact
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public string? Website { get; set; }
|
||||
|
||||
public string? Timezone { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SeedMetadata
|
||||
{
|
||||
public string? CveOrgId { get; set; }
|
||||
|
||||
public string? CsafPublisherId { get; set; }
|
||||
|
||||
public string? SecurityAdvisoriesUrl { get; set; }
|
||||
|
||||
public string? CatalogUrl { get; set; }
|
||||
|
||||
public List<string> Languages { get; set; } = new();
|
||||
|
||||
public Dictionary<string, string> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class SeedEndpoint
|
||||
{
|
||||
public string Kind { get; set; } = "csaf";
|
||||
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
public string? Format { get; set; }
|
||||
|
||||
public bool RequiresAuth { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Audit;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Internal;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Options;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Repositories;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Infrastructure;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddIssuerDirectoryInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddOptions<IssuerDirectoryMongoOptions>()
|
||||
.Bind(configuration.GetSection(IssuerDirectoryMongoOptions.SectionName))
|
||||
.Validate(options =>
|
||||
{
|
||||
options.Validate();
|
||||
return true;
|
||||
});
|
||||
|
||||
services.AddSingleton<IssuerDirectoryMongoContext>();
|
||||
services.AddSingleton<IIssuerRepository, MongoIssuerRepository>();
|
||||
services.AddSingleton<IIssuerKeyRepository, MongoIssuerKeyRepository>();
|
||||
services.AddSingleton<IIssuerTrustRepository, MongoIssuerTrustRepository>();
|
||||
services.AddSingleton<IIssuerAuditSink, MongoIssuerAuditSink>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="MongoDB.Bson" Version="3.5.0" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.IssuerDirectory.Core\\StellaOps.IssuerDirectory.Core.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Configuration\\StellaOps.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.IssuerDirectory.WebService.Constants;
|
||||
|
||||
internal static class IssuerDirectoryHeaders
|
||||
{
|
||||
public const string AuditReason = "X-StellaOps-Reason";
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Contracts;
|
||||
|
||||
public sealed record IssuerResponse(
|
||||
string Id,
|
||||
string TenantId,
|
||||
string DisplayName,
|
||||
string Slug,
|
||||
string? Description,
|
||||
IssuerContactResponse Contact,
|
||||
IssuerMetadataResponse Metadata,
|
||||
IReadOnlyCollection<IssuerEndpointResponse> Endpoints,
|
||||
IReadOnlyCollection<string> Tags,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
string CreatedBy,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string UpdatedBy,
|
||||
bool IsSystemSeed)
|
||||
{
|
||||
public static IssuerResponse FromDomain(IssuerRecord record)
|
||||
{
|
||||
return new IssuerResponse(
|
||||
record.Id,
|
||||
record.TenantId,
|
||||
record.DisplayName,
|
||||
record.Slug,
|
||||
record.Description,
|
||||
IssuerContactResponse.FromDomain(record.Contact),
|
||||
IssuerMetadataResponse.FromDomain(record.Metadata),
|
||||
record.Endpoints.Select(IssuerEndpointResponse.FromDomain).ToArray(),
|
||||
record.Tags,
|
||||
record.CreatedAtUtc,
|
||||
record.CreatedBy,
|
||||
record.UpdatedAtUtc,
|
||||
record.UpdatedBy,
|
||||
record.IsSystemSeed);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerContactResponse(string? Email, string? Phone, string? Website, string? Timezone)
|
||||
{
|
||||
public static IssuerContactResponse FromDomain(IssuerContact contact)
|
||||
{
|
||||
return new IssuerContactResponse(
|
||||
contact.Email,
|
||||
contact.Phone,
|
||||
contact.Website?.ToString(),
|
||||
contact.Timezone);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerMetadataResponse(
|
||||
string? CveOrgId,
|
||||
string? CsafPublisherId,
|
||||
string? SecurityAdvisoriesUrl,
|
||||
string? CatalogUrl,
|
||||
IReadOnlyCollection<string> Languages,
|
||||
IReadOnlyDictionary<string, string> Attributes)
|
||||
{
|
||||
public static IssuerMetadataResponse FromDomain(IssuerMetadata metadata)
|
||||
{
|
||||
return new IssuerMetadataResponse(
|
||||
metadata.CveOrgId,
|
||||
metadata.CsafPublisherId,
|
||||
metadata.SecurityAdvisoriesUrl?.ToString(),
|
||||
metadata.CatalogUrl?.ToString(),
|
||||
metadata.SupportedLanguages,
|
||||
metadata.Attributes);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerEndpointResponse(string Kind, string Url, string? Format, bool RequiresAuthentication)
|
||||
{
|
||||
public static IssuerEndpointResponse FromDomain(IssuerEndpoint endpoint)
|
||||
{
|
||||
return new IssuerEndpointResponse(
|
||||
endpoint.Kind,
|
||||
endpoint.Url.ToString(),
|
||||
endpoint.Format,
|
||||
endpoint.RequiresAuthentication);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerUpsertRequest
|
||||
{
|
||||
[Required]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Slug { get; init; } = string.Empty;
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
public IssuerContactRequest Contact { get; init; } = new();
|
||||
|
||||
public IssuerMetadataRequest Metadata { get; init; } = new();
|
||||
|
||||
public List<IssuerEndpointRequest> Endpoints { get; init; } = new();
|
||||
|
||||
public List<string> Tags { get; init; } = new();
|
||||
|
||||
public IssuerContact ToDomainContact()
|
||||
{
|
||||
return new IssuerContact(
|
||||
Contact.Email,
|
||||
Contact.Phone,
|
||||
string.IsNullOrWhiteSpace(Contact.Website) ? null : new Uri(Contact.Website),
|
||||
Contact.Timezone);
|
||||
}
|
||||
|
||||
public IssuerMetadata ToDomainMetadata()
|
||||
{
|
||||
return new IssuerMetadata(
|
||||
Metadata.CveOrgId,
|
||||
Metadata.CsafPublisherId,
|
||||
string.IsNullOrWhiteSpace(Metadata.SecurityAdvisoriesUrl)
|
||||
? null
|
||||
: new Uri(Metadata.SecurityAdvisoriesUrl),
|
||||
string.IsNullOrWhiteSpace(Metadata.CatalogUrl)
|
||||
? null
|
||||
: new Uri(Metadata.CatalogUrl),
|
||||
Metadata.Languages,
|
||||
Metadata.Attributes);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<IssuerEndpoint> ToDomainEndpoints()
|
||||
{
|
||||
return Endpoints.Select(endpoint => new IssuerEndpoint(
|
||||
endpoint.Kind,
|
||||
new Uri(endpoint.Url),
|
||||
endpoint.Format,
|
||||
endpoint.RequiresAuth)).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerContactRequest
|
||||
{
|
||||
public string? Email { get; init; }
|
||||
|
||||
public string? Phone { get; init; }
|
||||
|
||||
public string? Website { get; init; }
|
||||
|
||||
public string? Timezone { get; init; }
|
||||
}
|
||||
|
||||
public sealed record IssuerMetadataRequest
|
||||
{
|
||||
public string? CveOrgId { get; init; }
|
||||
|
||||
public string? CsafPublisherId { get; init; }
|
||||
|
||||
public string? SecurityAdvisoriesUrl { get; init; }
|
||||
|
||||
public string? CatalogUrl { get; init; }
|
||||
|
||||
public List<string> Languages { get; init; } = new();
|
||||
|
||||
public Dictionary<string, string> Attributes { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed record IssuerEndpointRequest
|
||||
{
|
||||
[Required]
|
||||
public string Kind { get; init; } = "csaf";
|
||||
|
||||
[Required]
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string? Format { get; init; }
|
||||
|
||||
public bool RequiresAuth { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Contracts;
|
||||
|
||||
public sealed record IssuerKeyResponse(
|
||||
string Id,
|
||||
string IssuerId,
|
||||
string TenantId,
|
||||
string Type,
|
||||
string Status,
|
||||
string MaterialFormat,
|
||||
string Fingerprint,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
string CreatedBy,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string UpdatedBy,
|
||||
DateTimeOffset? ExpiresAtUtc,
|
||||
DateTimeOffset? RetiredAtUtc,
|
||||
DateTimeOffset? RevokedAtUtc,
|
||||
string? ReplacesKeyId)
|
||||
{
|
||||
public static IssuerKeyResponse FromDomain(IssuerKeyRecord record)
|
||||
{
|
||||
return new IssuerKeyResponse(
|
||||
record.Id,
|
||||
record.IssuerId,
|
||||
record.TenantId,
|
||||
record.Type.ToString(),
|
||||
record.Status.ToString(),
|
||||
record.Material.Format,
|
||||
record.Fingerprint,
|
||||
record.CreatedAtUtc,
|
||||
record.CreatedBy,
|
||||
record.UpdatedAtUtc,
|
||||
record.UpdatedBy,
|
||||
record.ExpiresAtUtc,
|
||||
record.RetiredAtUtc,
|
||||
record.RevokedAtUtc,
|
||||
record.ReplacesKeyId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerKeyCreateRequest
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
public string Value { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset? ExpiresAtUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed record IssuerKeyRotateRequest
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
public string Value { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset? ExpiresAtUtc { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.IssuerDirectory.Core.Services;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Contracts;
|
||||
|
||||
public sealed record IssuerTrustResponse(
|
||||
TrustOverrideSummary? TenantOverride,
|
||||
TrustOverrideSummary? GlobalOverride,
|
||||
decimal EffectiveWeight)
|
||||
{
|
||||
public static IssuerTrustResponse FromView(IssuerTrustView view)
|
||||
{
|
||||
return new IssuerTrustResponse(
|
||||
view.TenantOverride is null ? null : TrustOverrideSummary.FromRecord(view.TenantOverride),
|
||||
view.GlobalOverride is null ? null : TrustOverrideSummary.FromRecord(view.GlobalOverride),
|
||||
view.EffectiveWeight);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TrustOverrideSummary(
|
||||
decimal Weight,
|
||||
string? Reason,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string UpdatedBy,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
string CreatedBy)
|
||||
{
|
||||
public static TrustOverrideSummary FromRecord(Core.Domain.IssuerTrustOverrideRecord record)
|
||||
{
|
||||
return new TrustOverrideSummary(
|
||||
record.Weight,
|
||||
record.Reason,
|
||||
record.UpdatedAtUtc,
|
||||
record.UpdatedBy,
|
||||
record.CreatedAtUtc,
|
||||
record.CreatedBy);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IssuerTrustSetRequest
|
||||
{
|
||||
public decimal Weight { get; init; }
|
||||
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.IssuerDirectory.Core.Services;
|
||||
using StellaOps.IssuerDirectory.WebService.Constants;
|
||||
using StellaOps.IssuerDirectory.WebService.Contracts;
|
||||
using StellaOps.IssuerDirectory.WebService.Options;
|
||||
using StellaOps.IssuerDirectory.WebService.Security;
|
||||
using StellaOps.IssuerDirectory.WebService.Services;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Endpoints;
|
||||
|
||||
public static class IssuerEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapIssuerEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/issuer-directory/issuers")
|
||||
.WithTags("Issuer Directory");
|
||||
|
||||
group.MapGet(string.Empty, ListIssuers)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Reader)
|
||||
.WithName("IssuerDirectory_ListIssuers");
|
||||
|
||||
group.MapGet("{id}", GetIssuer)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Reader)
|
||||
.WithName("IssuerDirectory_GetIssuer");
|
||||
|
||||
group.MapPost(string.Empty, CreateIssuer)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Writer)
|
||||
.WithName("IssuerDirectory_CreateIssuer");
|
||||
|
||||
group.MapPut("{id}", UpdateIssuer)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Writer)
|
||||
.WithName("IssuerDirectory_UpdateIssuer");
|
||||
|
||||
group.MapDelete("{id}", DeleteIssuer)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Admin)
|
||||
.WithName("IssuerDirectory_DeleteIssuer");
|
||||
|
||||
group.MapIssuerKeyEndpoints();
|
||||
group.MapIssuerTrustEndpoints();
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListIssuers(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerDirectoryService service,
|
||||
[FromQuery] bool includeGlobal = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var issuers = await service.ListAsync(tenantId, includeGlobal, cancellationToken).ConfigureAwait(false);
|
||||
var response = issuers.Select(IssuerResponse.FromDomain).ToArray();
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetIssuer(
|
||||
HttpContext context,
|
||||
[FromRoute] string id,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerDirectoryService service,
|
||||
[FromQuery] bool includeGlobal = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var issuer = await service.GetAsync(tenantId, id, includeGlobal, cancellationToken).ConfigureAwait(false);
|
||||
if (issuer is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(IssuerResponse.FromDomain(issuer));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateIssuer(
|
||||
HttpContext context,
|
||||
[FromBody] IssuerUpsertRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerDirectoryService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = ActorResolver.Resolve(context);
|
||||
var reason = ResolveAuditReason(context);
|
||||
|
||||
var issuer = await service.CreateAsync(
|
||||
tenantId,
|
||||
request.Id,
|
||||
request.DisplayName,
|
||||
request.Slug,
|
||||
request.Description,
|
||||
request.ToDomainContact(),
|
||||
request.ToDomainMetadata(),
|
||||
request.ToDomainEndpoints(),
|
||||
request.Tags,
|
||||
actor,
|
||||
reason,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/issuer-directory/issuers/{issuer.Id}", IssuerResponse.FromDomain(issuer));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateIssuer(
|
||||
HttpContext context,
|
||||
[FromRoute] string id,
|
||||
[FromBody] IssuerUpsertRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerDirectoryService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.Equals(id, request.Id, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Identifier mismatch",
|
||||
Detail = "Route identifier does not match request body."
|
||||
});
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = ActorResolver.Resolve(context);
|
||||
var reason = ResolveAuditReason(context);
|
||||
|
||||
var issuer = await service.UpdateAsync(
|
||||
tenantId,
|
||||
id,
|
||||
request.DisplayName,
|
||||
request.Description,
|
||||
request.ToDomainContact(),
|
||||
request.ToDomainMetadata(),
|
||||
request.ToDomainEndpoints(),
|
||||
request.Tags,
|
||||
actor,
|
||||
reason,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(IssuerResponse.FromDomain(issuer));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteIssuer(
|
||||
HttpContext context,
|
||||
[FromRoute] string id,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerDirectoryService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = ActorResolver.Resolve(context);
|
||||
var reason = ResolveAuditReason(context);
|
||||
|
||||
await service.DeleteAsync(tenantId, id, actor, reason, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static string? ResolveAuditReason(HttpContext context)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue(IssuerDirectoryHeaders.AuditReason, out var value))
|
||||
{
|
||||
var reason = value.ToString();
|
||||
return string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Core.Services;
|
||||
using StellaOps.IssuerDirectory.WebService.Constants;
|
||||
using StellaOps.IssuerDirectory.WebService.Contracts;
|
||||
using StellaOps.IssuerDirectory.WebService.Security;
|
||||
using StellaOps.IssuerDirectory.WebService.Services;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Endpoints;
|
||||
|
||||
internal static class IssuerKeyEndpoints
|
||||
{
|
||||
public static void MapIssuerKeyEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
var keysGroup = group.MapGroup("{issuerId}/keys");
|
||||
|
||||
keysGroup.MapGet(string.Empty, ListKeys)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Reader)
|
||||
.WithName("IssuerDirectory_ListIssuerKeys");
|
||||
|
||||
keysGroup.MapPost(string.Empty, CreateKey)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Writer)
|
||||
.WithName("IssuerDirectory_CreateIssuerKey");
|
||||
|
||||
keysGroup.MapPost("{keyId}/rotate", RotateKey)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Writer)
|
||||
.WithName("IssuerDirectory_RotateIssuerKey");
|
||||
|
||||
keysGroup.MapDelete("{keyId}", RevokeKey)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Admin)
|
||||
.WithName("IssuerDirectory_RevokeIssuerKey");
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListKeys(
|
||||
HttpContext context,
|
||||
[FromRoute] string issuerId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerKeyService keyService,
|
||||
[FromQuery] bool includeGlobal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var keys = await keyService.ListAsync(tenantId, issuerId, includeGlobal, cancellationToken).ConfigureAwait(false);
|
||||
var response = keys.Select(IssuerKeyResponse.FromDomain).ToArray();
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateKey(
|
||||
HttpContext context,
|
||||
[FromRoute] string issuerId,
|
||||
[FromBody] IssuerKeyCreateRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerKeyService keyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = ActorResolver.Resolve(context);
|
||||
var reason = ResolveReason(context);
|
||||
|
||||
if (!TryParseType(request.Type, out var type, out var error))
|
||||
{
|
||||
return CreateBadRequest(error);
|
||||
}
|
||||
|
||||
var material = new IssuerKeyMaterial(request.Format, request.Value);
|
||||
|
||||
try
|
||||
{
|
||||
var record = await keyService.AddAsync(
|
||||
tenantId,
|
||||
issuerId,
|
||||
type,
|
||||
material,
|
||||
request.ExpiresAtUtc,
|
||||
actor,
|
||||
reason,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = IssuerKeyResponse.FromDomain(record);
|
||||
return Results.Created($"/issuer-directory/issuers/{issuerId}/keys/{record.Id}", response);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return CreateBadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> RotateKey(
|
||||
HttpContext context,
|
||||
[FromRoute] string issuerId,
|
||||
[FromRoute] string keyId,
|
||||
[FromBody] IssuerKeyRotateRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerKeyService keyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = ActorResolver.Resolve(context);
|
||||
var reason = ResolveReason(context);
|
||||
|
||||
if (!TryParseType(request.Type, out var type, out var error))
|
||||
{
|
||||
return CreateBadRequest(error);
|
||||
}
|
||||
|
||||
var material = new IssuerKeyMaterial(request.Format, request.Value);
|
||||
|
||||
try
|
||||
{
|
||||
var record = await keyService.RotateAsync(
|
||||
tenantId,
|
||||
issuerId,
|
||||
keyId,
|
||||
type,
|
||||
material,
|
||||
request.ExpiresAtUtc,
|
||||
actor,
|
||||
reason,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(IssuerKeyResponse.FromDomain(record));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return CreateBadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeKey(
|
||||
HttpContext context,
|
||||
[FromRoute] string issuerId,
|
||||
[FromRoute] string keyId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerKeyService keyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = ActorResolver.Resolve(context);
|
||||
var reason = ResolveReason(context);
|
||||
|
||||
try
|
||||
{
|
||||
await keyService.RevokeAsync(
|
||||
tenantId,
|
||||
issuerId,
|
||||
keyId,
|
||||
actor,
|
||||
reason,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return CreateBadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseType(string value, out IssuerKeyType type, out string error)
|
||||
{
|
||||
if (Enum.TryParse<IssuerKeyType>(value?.Trim(), ignoreCase: true, out type))
|
||||
{
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "Unsupported key type. Valid values: Ed25519PublicKey, X509Certificate, DssePublicKey.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? ResolveReason(HttpContext context)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue(IssuerDirectoryHeaders.AuditReason, out var value))
|
||||
{
|
||||
var reason = value.ToString();
|
||||
return string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IResult CreateBadRequest(string message)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.IssuerDirectory.Core.Services;
|
||||
using StellaOps.IssuerDirectory.WebService.Constants;
|
||||
using StellaOps.IssuerDirectory.WebService.Contracts;
|
||||
using StellaOps.IssuerDirectory.WebService.Security;
|
||||
using StellaOps.IssuerDirectory.WebService.Services;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Endpoints;
|
||||
|
||||
internal static class IssuerTrustEndpoints
|
||||
{
|
||||
public static void MapIssuerTrustEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
var trustGroup = group.MapGroup("{issuerId}/trust");
|
||||
|
||||
trustGroup.MapGet(string.Empty, GetTrust)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Reader)
|
||||
.WithName("IssuerDirectory_GetTrust");
|
||||
|
||||
trustGroup.MapPut(string.Empty, SetTrust)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Writer)
|
||||
.WithName("IssuerDirectory_SetTrust");
|
||||
|
||||
trustGroup.MapDelete(string.Empty, DeleteTrust)
|
||||
.RequireAuthorization(IssuerDirectoryPolicies.Admin)
|
||||
.WithName("IssuerDirectory_DeleteTrust");
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTrust(
|
||||
HttpContext context,
|
||||
[FromRoute] string issuerId,
|
||||
[FromQuery] bool includeGlobal,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerTrustService trustService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var view = await trustService.GetAsync(tenantId, issuerId, includeGlobal, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(IssuerTrustResponse.FromView(view));
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetTrust(
|
||||
HttpContext context,
|
||||
[FromRoute] string issuerId,
|
||||
[FromBody] IssuerTrustSetRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerTrustService trustService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = ActorResolver.Resolve(context);
|
||||
var reason = ResolveReason(context, request.Reason);
|
||||
|
||||
try
|
||||
{
|
||||
var record = await trustService.SetAsync(
|
||||
tenantId,
|
||||
issuerId,
|
||||
request.Weight,
|
||||
reason,
|
||||
actor,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var view = await trustService.GetAsync(tenantId, issuerId, includeGlobal: true, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(IssuerTrustResponse.FromView(view));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteTrust(
|
||||
HttpContext context,
|
||||
[FromRoute] string issuerId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IssuerTrustService trustService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = ActorResolver.Resolve(context);
|
||||
var reason = ResolveReason(context, null);
|
||||
|
||||
await trustService.DeleteAsync(tenantId, issuerId, actor, reason, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult BadRequest(string message)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = message
|
||||
});
|
||||
}
|
||||
|
||||
private static string? ResolveReason(HttpContext context, string? bodyReason)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue(IssuerDirectoryHeaders.AuditReason, out var value))
|
||||
{
|
||||
var headerReason = value.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(headerReason))
|
||||
{
|
||||
return headerReason.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(bodyReason) ? null : bodyReason.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Options;
|
||||
|
||||
public sealed class IssuerDirectoryWebServiceOptions
|
||||
{
|
||||
public const string SectionName = "IssuerDirectory";
|
||||
|
||||
public TelemetryOptions Telemetry { get; set; } = new();
|
||||
|
||||
public AuthorityOptions Authority { get; set; } = new();
|
||||
|
||||
public string TenantHeader { get; set; } = "X-StellaOps-Tenant";
|
||||
|
||||
public bool SeedCsafPublishers { get; set; } = true;
|
||||
|
||||
public string CsafSeedPath { get; set; } = "csaf-publishers.json";
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(TenantHeader))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant header must be configured.");
|
||||
}
|
||||
|
||||
Authority.Validate();
|
||||
}
|
||||
|
||||
public sealed class TelemetryOptions
|
||||
{
|
||||
public string MinimumLogLevel { get; set; } = "Information";
|
||||
}
|
||||
|
||||
public sealed class AuthorityOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
|
||||
public IList<string> Audiences { get; set; } = new List<string>();
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public IList<string> Scopes { get; set; } = new List<string>
|
||||
{
|
||||
StellaOpsScopes.IssuerDirectoryRead,
|
||||
StellaOpsScopes.IssuerDirectoryWrite,
|
||||
StellaOpsScopes.IssuerDirectoryAdmin
|
||||
};
|
||||
|
||||
public string ReadScope { get; set; } = StellaOpsScopes.IssuerDirectoryRead;
|
||||
|
||||
public string WriteScope { get; set; } = StellaOpsScopes.IssuerDirectoryWrite;
|
||||
|
||||
public string AdminScope { get; set; } = StellaOpsScopes.IssuerDirectoryAdmin;
|
||||
|
||||
public IList<string> ClientScopes { get; set; } = new List<string>();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Issuer))
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory authority issuer is required when enabled.");
|
||||
}
|
||||
|
||||
if (Audiences.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory authority audiences must be configured.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.IssuerDirectory.Core.Services;
|
||||
using StellaOps.IssuerDirectory.Infrastructure;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Seed;
|
||||
using StellaOps.IssuerDirectory.WebService.Endpoints;
|
||||
using StellaOps.IssuerDirectory.WebService.Options;
|
||||
using StellaOps.IssuerDirectory.WebService.Security;
|
||||
using StellaOps.IssuerDirectory.WebService.Services;
|
||||
|
||||
const string ConfigurationPrefix = "ISSUERDIRECTORY_";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = ConfigurationPrefix;
|
||||
options.BindingSection = IssuerDirectoryWebServiceOptions.SectionName;
|
||||
});
|
||||
|
||||
var bootstrapOptions = builder.Configuration.BindOptions<IssuerDirectoryWebServiceOptions>(
|
||||
IssuerDirectoryWebServiceOptions.SectionName,
|
||||
static (options, _) => options.Validate());
|
||||
|
||||
builder.Host.UseSerilog((context, services, configuration) =>
|
||||
{
|
||||
var minLevel = MapLogLevel(bootstrapOptions.Telemetry.MinimumLogLevel);
|
||||
configuration
|
||||
.MinimumLevel.Is(minLevel)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console();
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<IssuerDirectoryWebServiceOptions>()
|
||||
.Bind(builder.Configuration.GetSection(IssuerDirectoryWebServiceOptions.SectionName))
|
||||
.Validate(options =>
|
||||
{
|
||||
options.Validate();
|
||||
return true;
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddIssuerDirectoryInfrastructure(builder.Configuration);
|
||||
builder.Services.AddSingleton<IssuerDirectoryService>();
|
||||
builder.Services.AddSingleton<IssuerKeyService>();
|
||||
builder.Services.AddSingleton<IssuerTrustService>();
|
||||
builder.Services.AddSingleton<TenantResolver>();
|
||||
builder.Services.AddSingleton<CsafPublisherSeedLoader>();
|
||||
|
||||
ConfigureAuthentication(builder, bootstrapOptions);
|
||||
|
||||
builder.Services.AddAuthorization(auth =>
|
||||
{
|
||||
if (bootstrapOptions.Authority.Enabled)
|
||||
{
|
||||
auth.AddPolicy(IssuerDirectoryPolicies.Reader, policy => policy.RequireScope(bootstrapOptions.Authority.ReadScope));
|
||||
auth.AddPolicy(IssuerDirectoryPolicies.Writer, policy => policy.RequireScope(bootstrapOptions.Authority.WriteScope));
|
||||
auth.AddPolicy(IssuerDirectoryPolicies.Admin, policy => policy.RequireScope(bootstrapOptions.Authority.AdminScope));
|
||||
}
|
||||
else
|
||||
{
|
||||
auth.AddPolicy(IssuerDirectoryPolicies.Reader, policy => policy.RequireAssertion(static _ => true));
|
||||
auth.AddPolicy(IssuerDirectoryPolicies.Writer, policy => policy.RequireAssertion(static _ => true));
|
||||
auth.AddPolicy(IssuerDirectoryPolicies.Admin, policy => policy.RequireAssertion(static _ => true));
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddHealthChecks().AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy());
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddMeter("StellaOps.IssuerDirectory")
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddRuntimeInstrumentation())
|
||||
.WithTracing(tracing => tracing.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation());
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
var issuerGroup = app.MapIssuerEndpoints();
|
||||
|
||||
var seedingTask = SeedPublishersAsync(app.Services, app.Environment);
|
||||
await seedingTask.ConfigureAwait(false);
|
||||
|
||||
app.Run();
|
||||
|
||||
static LogEventLevel MapLogLevel(string? value)
|
||||
{
|
||||
return Enum.TryParse<LogEventLevel>(value, ignoreCase: true, out var level)
|
||||
? level
|
||||
: LogEventLevel.Information;
|
||||
}
|
||||
|
||||
static void ConfigureAuthentication(
|
||||
WebApplicationBuilder builder,
|
||||
IssuerDirectoryWebServiceOptions options)
|
||||
{
|
||||
if (options.Authority.Enabled)
|
||||
{
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
resourceOptions.Authority = options.Authority.Issuer;
|
||||
resourceOptions.RequireHttpsMetadata = options.Authority.RequireHttpsMetadata;
|
||||
foreach (var audience in options.Authority.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthentication(AllowAnonymousAuthenticationHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, AllowAnonymousAuthenticationHandler>(
|
||||
AllowAnonymousAuthenticationHandler.SchemeName,
|
||||
static _ => { });
|
||||
}
|
||||
}
|
||||
|
||||
static async Task SeedPublishersAsync(IServiceProvider services, IWebHostEnvironment environment)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<IssuerDirectoryWebServiceOptions>>().Value;
|
||||
if (!options.SeedCsafPublishers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var loader = scope.ServiceProvider.GetRequiredService<CsafPublisherSeedLoader>();
|
||||
var service = scope.ServiceProvider.GetRequiredService<IssuerDirectoryService>();
|
||||
|
||||
var path = options.CsafSeedPath;
|
||||
if (!Path.IsPathRooted(path))
|
||||
{
|
||||
path = Path.Combine(environment.ContentRootPath, path);
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Warning("CSAF seed file {SeedPath} not found; skipping issuer bootstrap.", path);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
var records = loader.Load(stream, actor: "issuer-directory-seed");
|
||||
await service.SeedAsync(records, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal static class AuthorizationPolicyBuilderExtensions
|
||||
{
|
||||
public static AuthorizationPolicyBuilder RequireScope(this AuthorizationPolicyBuilder policy, string scope)
|
||||
{
|
||||
return policy.RequireAuthenticatedUser()
|
||||
.RequireAssertion(context => context.User.HasScope(scope));
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ClaimsPrincipalExtensions
|
||||
{
|
||||
public static bool HasScope(this ClaimsPrincipal principal, string scope)
|
||||
{
|
||||
return principal.FindAll("scope").Concat(principal.FindAll("scp"))
|
||||
.SelectMany(value => value.Value.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
.Any(value => string.Equals(value, scope, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AllowAnonymousAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "AllowAnonymous";
|
||||
|
||||
#pragma warning disable CS0618
|
||||
public AllowAnonymousAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock)
|
||||
: base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var identity = new ClaimsIdentity(SchemeName);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.IssuerDirectory.WebService.Security;
|
||||
|
||||
public static class IssuerDirectoryPolicies
|
||||
{
|
||||
public const string Reader = "IssuerDirectory.Reader";
|
||||
public const string Writer = "IssuerDirectory.Writer";
|
||||
public const string Admin = "IssuerDirectory.Admin";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.IssuerDirectory.WebService.Services;
|
||||
|
||||
internal static class ActorResolver
|
||||
{
|
||||
public static string Resolve(HttpContext context)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
return context.User?.FindFirst("sub")?.Value
|
||||
?? context.User?.Identity?.Name
|
||||
?? context.User?.FindFirst("client_id")?.Value
|
||||
?? "anonymous";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Services;
|
||||
|
||||
internal static class ScopeAuthorization
|
||||
{
|
||||
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public static IResult? RequireScope(HttpContext context, string scope)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
throw new ArgumentException("Scope must be provided.", nameof(scope));
|
||||
}
|
||||
|
||||
var user = context.User;
|
||||
if (user?.Identity?.IsAuthenticated is not true)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!HasScope(user, scope))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasScope(ClaimsPrincipal principal, string scope)
|
||||
{
|
||||
foreach (var claim in principal.FindAll("scope").Concat(principal.FindAll("scp")))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scopes = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (scopes.Any(value => Comparer.Equals(value, scope)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.IssuerDirectory.WebService.Options;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.WebService.Services;
|
||||
|
||||
internal sealed class TenantResolver
|
||||
{
|
||||
private readonly IssuerDirectoryWebServiceOptions _options;
|
||||
|
||||
public TenantResolver(IOptions<IssuerDirectoryWebServiceOptions> options)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public string Resolve(HttpContext context)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.TryGetValue(_options.TenantHeader, out var values))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Tenant header '{_options.TenantHeader}' is required for Issuer Directory operations.");
|
||||
}
|
||||
|
||||
var tenantId = values.ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Tenant header '{_options.TenantHeader}' must contain a value.");
|
||||
}
|
||||
|
||||
return tenantId.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.IssuerDirectory.Core\\StellaOps.IssuerDirectory.Core.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.IssuerDirectory.Infrastructure\\StellaOps.IssuerDirectory.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\Authority\\StellaOps.Authority\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\Authority\\StellaOps.Authority\\StellaOps.Auth.ServerIntegration\\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Configuration\\StellaOps.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\\data\\csaf-publishers.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.IssuerDirectory.Core", "StellaOps.IssuerDirectory.Core\StellaOps.IssuerDirectory.Core.csproj", "{298FE21A-B406-486C-972C-E8CE6FE81D38}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.IssuerDirectory.Infrastructure", "StellaOps.IssuerDirectory.Infrastructure\StellaOps.IssuerDirectory.Infrastructure.csproj", "{0F76EF16-3194-4127-BC50-15F01E48F2B7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.IssuerDirectory.WebService", "StellaOps.IssuerDirectory.WebService\StellaOps.IssuerDirectory.WebService.csproj", "{8ECE3570-9BA0-470B-A8E3-C244F6AAEF92}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.IssuerDirectory.Core.Tests", "StellaOps.IssuerDirectory.Core.Tests\StellaOps.IssuerDirectory.Core.Tests.csproj", "{22842BC6-D909-4919-8FB1-B2C3ED7E4DDE}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{298FE21A-B406-486C-972C-E8CE6FE81D38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{298FE21A-B406-486C-972C-E8CE6FE81D38}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{298FE21A-B406-486C-972C-E8CE6FE81D38}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{298FE21A-B406-486C-972C-E8CE6FE81D38}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0F76EF16-3194-4127-BC50-15F01E48F2B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0F76EF16-3194-4127-BC50-15F01E48F2B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0F76EF16-3194-4127-BC50-15F01E48F2B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0F76EF16-3194-4127-BC50-15F01E48F2B7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8ECE3570-9BA0-470B-A8E3-C244F6AAEF92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8ECE3570-9BA0-470B-A8E3-C244F6AAEF92}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8ECE3570-9BA0-470B-A8E3-C244F6AAEF92}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8ECE3570-9BA0-470B-A8E3-C244F6AAEF92}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{22842BC6-D909-4919-8FB1-B2C3ED7E4DDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{22842BC6-D909-4919-8FB1-B2C3ED7E4DDE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{22842BC6-D909-4919-8FB1-B2C3ED7E4DDE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{22842BC6-D909-4919-8FB1-B2C3ED7E4DDE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {291CD30E-130B-4349-AD46-80801170D9F5}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -1,9 +1,11 @@
|
||||
# Issuer Directory Task Board — Epic 7
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ISSUER-30-001 | TODO | Issuer Directory Guild | AUTH-VULN-29-001 | Implement issuer CRUD API with RBAC, audit logging, and tenant scoping; seed CSAF publisher metadata. | APIs deployed; audit logs capture actor/reason; seed data imported; tests cover RBAC. |
|
||||
| ISSUER-30-002 | TODO | Issuer Directory Guild, Security Guild | ISSUER-30-001 | Implement key management endpoints (add/rotate/revoke keys), enforce expiry, validate formats (Ed25519, X.509, DSSE). | Keys stored securely; expiry enforced; validation tests cover key types; docs updated. |
|
||||
| ISSUER-30-003 | TODO | Issuer Directory Guild, Policy Guild | ISSUER-30-001 | Provide trust weight APIs and tenant overrides with validation (+/- bounds) and audit trails. | Trust overrides persisted; policy integration confirmed; tests cover overrides. |
|
||||
| ISSUER-30-004 | TODO | Issuer Directory Guild, VEX Lens Guild | ISSUER-30-001..003 | Integrate with VEX Lens and Excitator signature verification (client SDK, caching, retries). | Lens/Excitator resolve issuer metadata via SDK; integration tests cover network failures. |
|
||||
| ISSUER-30-005 | TODO | Issuer Directory Guild, Observability Guild | ISSUER-30-001..004 | Instrument metrics/logs (issuer changes, key rotation, verification failures) and dashboards/alerts. | Telemetry live; alerts configured; docs updated. |
|
||||
| ISSUER-30-006 | TODO | Issuer Directory Guild, DevOps Guild | ISSUER-30-001..005 | Provide deployment manifests, backup/restore, secure secret storage, and offline kit instructions. | Deployment docs merged; smoke deploy validated; backup tested; offline kit updated. |
|
||||
| ISSUER-30-001 | DONE (2025-11-01) | Issuer Directory Guild | AUTH-VULN-29-001 | Implement issuer CRUD API with RBAC, audit logging, and tenant scoping; seed CSAF publisher metadata. | APIs deployed; audit logs capture actor/reason; seed data imported; tests cover RBAC. |
|
||||
| ISSUER-30-002 | DONE (2025-11-01) | Issuer Directory Guild, Security Guild | ISSUER-30-001 | Implement key management endpoints (add/rotate/revoke keys), enforce expiry, validate formats (Ed25519, X.509, DSSE). | Keys stored securely; expiry enforced; validation tests cover key types; docs updated. |
|
||||
| ISSUER-30-003 | DOING | Issuer Directory Guild, Policy Guild | ISSUER-30-001 | Provide trust weight APIs and tenant overrides with validation (+/- bounds) and audit trails. | Trust overrides persisted; policy integration confirmed; tests cover overrides. |
|
||||
| ISSUER-30-004 | DONE (2025-11-01) | Issuer Directory Guild, VEX Lens Guild | ISSUER-30-001..003 | Integrate with VEX Lens and Excitator signature verification (client SDK, caching, retries). | Lens/Excitator resolve issuer metadata via SDK; integration tests cover network failures. |
|
||||
| ISSUER-30-005 | DONE (2025-11-01) | Issuer Directory Guild, Observability Guild | ISSUER-30-001..004 | Instrument metrics/logs (issuer changes, key rotation, verification failures) and dashboards/alerts. | Telemetry live; alerts configured; docs updated. |
|
||||
| ISSUER-30-006 | TODO | Issuer Directory Guild, DevOps Guild | ISSUER-30-001..005 | Provide deployment manifests, backup/restore, secure secret storage, and offline kit instructions. | Deployment docs merged; smoke deploy validated; backup tested; offline kit updated. |
|
||||
|
||||
> 2025-11-01: Excititor worker now queries Issuer Directory via during attestation verification, caching active key metadata and trust weights for tenant/global scopes.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
[
|
||||
{
|
||||
"id": "red-hat",
|
||||
"tenantId": "@global",
|
||||
"displayName": "Red Hat Product Security",
|
||||
"slug": "red-hat",
|
||||
"description": "Official CSAF publisher for Red Hat advisories.",
|
||||
"contact": {
|
||||
"email": "secalert@redhat.com",
|
||||
"website": "https://access.redhat.com/security/team/contact/"
|
||||
},
|
||||
"metadata": {
|
||||
"cveOrgId": "redhat",
|
||||
"csafPublisherId": "cpe:/o:redhat",
|
||||
"securityAdvisoriesUrl": "https://access.redhat.com/security/cve/",
|
||||
"catalogUrl": "https://access.redhat.com/security/data/csaf/v2/",
|
||||
"languages": [ "en" ],
|
||||
"attributes": {
|
||||
"distribution": "online",
|
||||
"license": "public"
|
||||
}
|
||||
},
|
||||
"endpoints": [
|
||||
{
|
||||
"kind": "csaf",
|
||||
"url": "https://access.redhat.com/security/data/csaf/v2/advisories",
|
||||
"format": "csaf",
|
||||
"requiresAuth": false
|
||||
}
|
||||
],
|
||||
"tags": [ "vendor", "linux", "csaf" ]
|
||||
},
|
||||
{
|
||||
"id": "microsoft",
|
||||
"tenantId": "@global",
|
||||
"displayName": "Microsoft Security",
|
||||
"slug": "microsoft",
|
||||
"description": "Microsoft vulnerability disclosure and CSAF catalog.",
|
||||
"contact": {
|
||||
"website": "https://www.microsoft.com/security/blog"
|
||||
},
|
||||
"metadata": {
|
||||
"cveOrgId": "microsoft",
|
||||
"csafPublisherId": "cpe:/o:microsoft",
|
||||
"securityAdvisoriesUrl": "https://msrc.microsoft.com/update-guide",
|
||||
"catalogUrl": "https://www.microsoft.com/en-us/msrc/csa",
|
||||
"languages": [ "en" ],
|
||||
"attributes": {
|
||||
"distribution": "online",
|
||||
"license": "public"
|
||||
}
|
||||
},
|
||||
"endpoints": [
|
||||
{
|
||||
"kind": "csaf",
|
||||
"url": "https://www.microsoft.com/en-us/msrc/csa",
|
||||
"format": "csaf",
|
||||
"requiresAuth": false
|
||||
}
|
||||
],
|
||||
"tags": [ "vendor", "windows", "csaf" ]
|
||||
},
|
||||
{
|
||||
"id": "cisco",
|
||||
"tenantId": "@global",
|
||||
"displayName": "Cisco PSIRT",
|
||||
"slug": "cisco",
|
||||
"description": "Cisco Product Security Incident Response Team advisories.",
|
||||
"contact": {
|
||||
"email": "psirt@cisco.com",
|
||||
"website": "https://sec.cloudapps.cisco.com/security/center/publicationListing.x"
|
||||
},
|
||||
"metadata": {
|
||||
"cveOrgId": "cisco",
|
||||
"csafPublisherId": "cpe:/o:cisco",
|
||||
"securityAdvisoriesUrl": "https://sec.cloudapps.cisco.com/security/center/publicationListing.x",
|
||||
"catalogUrl": "https://sec.cloudapps.cisco.com/security/vulnerabilitycenter/csaf",
|
||||
"languages": [ "en" ],
|
||||
"attributes": {
|
||||
"distribution": "online",
|
||||
"license": "public"
|
||||
}
|
||||
},
|
||||
"endpoints": [
|
||||
{
|
||||
"kind": "csaf",
|
||||
"url": "https://sec.cloudapps.cisco.com/security/vulnerabilitycenter/csaf",
|
||||
"format": "csaf",
|
||||
"requiresAuth": false
|
||||
}
|
||||
],
|
||||
"tags": [ "vendor", "networking", "csaf" ]
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user