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:
master
2025-11-02 13:40:38 +02:00
parent 66cb6c4b8a
commit f98cea3bcf
516 changed files with 68157 additions and 24754 deletions

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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.")
};
}
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.IssuerDirectory.Core.Domain;
/// <summary>
/// Lifecycle status for issuer keys.
/// </summary>
public enum IssuerKeyStatus
{
Active,
Retired,
Revoked
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.IssuerDirectory.Core.Domain;
/// <summary>
/// Supported issuer key kinds.
/// </summary>
public enum IssuerKeyType
{
Ed25519PublicKey,
X509Certificate,
DssePublicKey
}

View File

@@ -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);
}
}

View File

@@ -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()
};
}
}

View File

@@ -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";
}

View File

@@ -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.");
}
}
}

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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.");
}
}
}

View File

@@ -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
};
}
}

View File

@@ -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
};
}
}

View File

@@ -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
};
}
}

View File

@@ -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; }
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -0,0 +1,6 @@
namespace StellaOps.IssuerDirectory.WebService.Constants;
internal static class IssuerDirectoryHeaders
{
public const string AuditReason = "X-StellaOps-Reason";
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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
});
}
}

View File

@@ -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();
}
}

View File

@@ -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.");
}
}
}
}

View File

@@ -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));
}
}

View File

@@ -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";
}

View File

@@ -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";
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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.

View File

@@ -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" ]
}
]