feat: Add in-memory implementations for issuer audit, key, repository, and trust management
Some checks failed
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled

- Introduced InMemoryIssuerAuditSink to retain audit entries for testing.
- Implemented InMemoryIssuerKeyRepository for deterministic key storage.
- Created InMemoryIssuerRepository to manage issuer records in memory.
- Added InMemoryIssuerTrustRepository for managing issuer trust overrides.
- Each repository utilizes concurrent collections for thread-safe operations.
- Enhanced deprecation tracking with a comprehensive YAML schema for API governance.
This commit is contained in:
master
2025-12-11 19:47:43 +02:00
parent ab22181e8b
commit ce5ec9c158
48 changed files with 1898 additions and 1580 deletions

View File

@@ -1,35 +0,0 @@
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

@@ -1,31 +0,0 @@
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

@@ -1,103 +0,0 @@
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

@@ -1,55 +0,0 @@
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

@@ -1,34 +0,0 @@
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,27 @@
using System.Collections.Concurrent;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Infrastructure.InMemory;
/// <summary>
/// In-memory audit sink; retains last N entries for inspection/testing.
/// </summary>
internal sealed class InMemoryIssuerAuditSink : IIssuerAuditSink
{
private readonly ConcurrentQueue<IssuerAuditEntry> _entries = new();
private const int MaxEntries = 1024;
public Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(entry);
_entries.Enqueue(entry);
while (_entries.Count > MaxEntries && _entries.TryDequeue(out _))
{
// drop oldest to bound memory
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,88 @@
using System.Collections.Concurrent;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Infrastructure.InMemory;
/// <summary>
/// Deterministic in-memory issuer key store used as a Mongo replacement.
/// </summary>
internal sealed class InMemoryIssuerKeyRepository : IIssuerKeyRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, IssuerKeyRecord>> _keys = new(StringComparer.Ordinal);
public Task<IssuerKeyRecord?> GetAsync(string tenantId, string issuerId, string keyId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var bucketKey = GetBucketKey(tenantId, issuerId);
if (_keys.TryGetValue(bucketKey, out var map) && map.TryGetValue(keyId, out var record))
{
return Task.FromResult<IssuerKeyRecord?>(record);
}
return Task.FromResult<IssuerKeyRecord?>(null);
}
public Task<IssuerKeyRecord?> GetByFingerprintAsync(string tenantId, string issuerId, string fingerprint, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint);
var bucketKey = GetBucketKey(tenantId, issuerId);
if (_keys.TryGetValue(bucketKey, out var map))
{
var match = map.Values.FirstOrDefault(key => string.Equals(key.Fingerprint, fingerprint, StringComparison.Ordinal));
return Task.FromResult<IssuerKeyRecord?>(match);
}
return Task.FromResult<IssuerKeyRecord?>(null);
}
public Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var bucketKey = GetBucketKey(tenantId, issuerId);
if (_keys.TryGetValue(bucketKey, out var map))
{
var ordered = map.Values.OrderBy(k => k.Id, StringComparer.Ordinal).ToArray();
return Task.FromResult<IReadOnlyCollection<IssuerKeyRecord>>(ordered);
}
return Task.FromResult<IReadOnlyCollection<IssuerKeyRecord>>(Array.Empty<IssuerKeyRecord>());
}
public Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(string issuerId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var all = _keys.Values
.SelectMany(dict => dict.Values)
.Where(k => string.Equals(k.IssuerId, issuerId, StringComparison.Ordinal))
.OrderBy(k => k.TenantId, StringComparer.Ordinal)
.ThenBy(k => k.Id, StringComparer.Ordinal)
.ToArray();
return Task.FromResult<IReadOnlyCollection<IssuerKeyRecord>>(all);
}
public Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
var bucketKey = GetBucketKey(record.TenantId, record.IssuerId);
var map = _keys.GetOrAdd(bucketKey, _ => new ConcurrentDictionary<string, IssuerKeyRecord>(StringComparer.Ordinal));
map.AddOrUpdate(record.Id, record, (_, _) => record);
return Task.CompletedTask;
}
private static string GetBucketKey(string tenantId, string issuerId)
{
return $"{tenantId}|{issuerId}";
}
}

View File

@@ -0,0 +1,72 @@
using System.Collections.Concurrent;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Infrastructure.InMemory;
/// <summary>
/// Deterministic in-memory issuer store used as a Mongo replacement.
/// </summary>
internal sealed class InMemoryIssuerRepository : IIssuerRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, IssuerRecord>> _issuers = new(StringComparer.Ordinal);
public Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
if (_issuers.TryGetValue(tenantId, out var map) && map.TryGetValue(issuerId, out var record))
{
return Task.FromResult<IssuerRecord?>(record);
}
return Task.FromResult<IssuerRecord?>(null);
}
public Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
if (_issuers.TryGetValue(tenantId, out var map))
{
var ordered = map.Values.OrderBy(r => r.Id, StringComparer.Ordinal).ToArray();
return Task.FromResult<IReadOnlyCollection<IssuerRecord>>(ordered);
}
return Task.FromResult<IReadOnlyCollection<IssuerRecord>>(Array.Empty<IssuerRecord>());
}
public Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
{
var ordered = _issuers.Values
.SelectMany(dict => dict.Values)
.OrderBy(r => r.TenantId, StringComparer.Ordinal)
.ThenBy(r => r.Id, StringComparer.Ordinal)
.ToArray();
return Task.FromResult<IReadOnlyCollection<IssuerRecord>>(ordered);
}
public Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
var tenantMap = _issuers.GetOrAdd(record.TenantId, _ => new ConcurrentDictionary<string, IssuerRecord>(StringComparer.Ordinal));
tenantMap.AddOrUpdate(record.Id, record, (_, _) => record);
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
if (_issuers.TryGetValue(tenantId, out var map))
{
map.TryRemove(issuerId, out _);
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,42 @@
using System.Collections.Concurrent;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Infrastructure.InMemory;
/// <summary>
/// Deterministic in-memory trust override store used as a Mongo replacement.
/// </summary>
internal sealed class InMemoryIssuerTrustRepository : IIssuerTrustRepository
{
private readonly ConcurrentDictionary<string, IssuerTrustOverrideRecord> _trust = new(StringComparer.Ordinal);
public Task<IssuerTrustOverrideRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var key = GetKey(tenantId, issuerId);
return Task.FromResult(_trust.TryGetValue(key, out var record) ? record : null);
}
public Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
var key = GetKey(record.TenantId, record.IssuerId);
_trust[key] = record;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
var key = GetKey(tenantId, issuerId);
_trust.TryRemove(key, out _);
return Task.CompletedTask;
}
private static string GetKey(string tenantId, string issuerId) => $"{tenantId}|{issuerId}";
}

View File

@@ -1,103 +0,0 @@
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

@@ -1,54 +0,0 @@
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

@@ -1,131 +0,0 @@
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

@@ -1,177 +0,0 @@
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

@@ -1,88 +0,0 @@
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

@@ -1,10 +1,7 @@
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;
using StellaOps.IssuerDirectory.Infrastructure.InMemory;
namespace StellaOps.IssuerDirectory.Infrastructure;
@@ -17,19 +14,10 @@ public static class ServiceCollectionExtensions
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>();
services.AddSingleton<IIssuerRepository, InMemoryIssuerRepository>();
services.AddSingleton<IIssuerKeyRepository, InMemoryIssuerKeyRepository>();
services.AddSingleton<IIssuerTrustRepository, InMemoryIssuerTrustRepository>();
services.AddSingleton<IIssuerAuditSink, InMemoryIssuerAuditSink>();
return services;
}

View File

@@ -11,8 +11,6 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<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" />

View File

@@ -121,7 +121,7 @@ static void ConfigurePersistence(
WebApplicationBuilder builder,
IssuerDirectoryWebServiceOptions options)
{
var provider = options.Persistence.Provider?.Trim().ToLowerInvariant() ?? "mongo";
var provider = options.Persistence.Provider?.Trim().ToLowerInvariant() ?? "postgres";
if (provider == "postgres")
{
@@ -134,7 +134,7 @@ static void ConfigurePersistence(
}
else
{
Log.Information("Using MongoDB persistence for IssuerDirectory.");
Log.Information("Using in-memory persistence for IssuerDirectory (non-production).");
builder.Services.AddIssuerDirectoryInfrastructure(builder.Configuration);
}
}