feat: Add CVSS receipt management endpoints and related functionality
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

- Introduced new API endpoints for creating, retrieving, amending, and listing CVSS receipts.
- Updated IPolicyEngineClient interface to include methods for CVSS receipt operations.
- Implemented PolicyEngineClient to handle CVSS receipt requests.
- Enhanced Program.cs to map new CVSS receipt routes with appropriate authorization.
- Added necessary models and contracts for CVSS receipt requests and responses.
- Integrated Postgres document store for managing CVSS receipts and related data.
- Updated database schema with new migrations for source documents and payload storage.
- Refactored existing components to support new CVSS functionality.
This commit is contained in:
StellaOps Bot
2025-12-07 00:43:14 +02:00
parent 0de92144d2
commit 53889d85e7
67 changed files with 17207 additions and 16293 deletions

View File

@@ -0,0 +1,88 @@
using System.Text.Json;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Concelier.Storage.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres;
/// <summary>
/// Postgres-backed implementation that satisfies the legacy IDocumentStore contract.
/// </summary>
public sealed class PostgresDocumentStore : IDocumentStore
{
private readonly IDocumentRepository _repository;
private readonly ISourceRepository _sourceRepository;
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
public PostgresDocumentStore(IDocumentRepository repository, ISourceRepository sourceRepository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_sourceRepository = sourceRepository ?? throw new ArgumentNullException(nameof(sourceRepository));
}
public async Task<DocumentRecord?> FindAsync(Guid id, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
{
var row = await _repository.FindAsync(id, cancellationToken).ConfigureAwait(false);
return row is null ? null : Map(row);
}
public async Task<DocumentRecord?> FindBySourceAndUriAsync(string sourceName, string uri, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
{
var row = await _repository.FindBySourceAndUriAsync(sourceName, uri, cancellationToken).ConfigureAwait(false);
return row is null ? null : Map(row);
}
public async Task<DocumentRecord> UpsertAsync(DocumentRecord record, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
{
// Ensure source exists
var source = await _sourceRepository.GetByNameAsync(record.SourceName, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException($"Source '{record.SourceName}' not provisioned.");
var entity = new DocumentRecordEntity(
Id: record.Id == Guid.Empty ? Guid.NewGuid() : record.Id,
SourceId: source.Id,
SourceName: record.SourceName,
Uri: record.Uri,
Sha256: record.Sha256,
Status: record.Status,
ContentType: record.ContentType,
HeadersJson: record.Headers is null ? null : JsonSerializer.Serialize(record.Headers, _json),
MetadataJson: record.Metadata is null ? null : JsonSerializer.Serialize(record.Metadata, _json),
Etag: record.Etag,
LastModified: record.LastModified,
Payload: Array.Empty<byte>(), // payload handled via RawDocumentStorage; keep pointer zero-length here
CreatedAt: record.CreatedAt,
UpdatedAt: DateTimeOffset.UtcNow,
ExpiresAt: record.ExpiresAt);
var saved = await _repository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false);
return Map(saved);
}
public async Task UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
{
await _repository.UpdateStatusAsync(id, status, cancellationToken).ConfigureAwait(false);
}
private DocumentRecord Map(DocumentRecordEntity row)
{
return new DocumentRecord(
row.Id,
row.SourceName,
row.Uri,
row.CreatedAt,
row.Sha256,
row.Status,
row.ContentType,
row.HeadersJson is null
? null
: JsonSerializer.Deserialize<Dictionary<string, string>>(row.HeadersJson, _json),
row.MetadataJson is null
? null
: JsonSerializer.Deserialize<Dictionary<string, string>>(row.MetadataJson, _json),
row.Etag,
row.LastModified,
PayloadId: null,
ExpiresAt: row.ExpiresAt);
}
}

View File

@@ -0,0 +1,23 @@
-- Concelier Postgres Migration 004: Source documents and payload storage (Mongo replacement)
CREATE TABLE IF NOT EXISTS concelier.source_documents (
id UUID NOT NULL,
source_id UUID NOT NULL,
source_name TEXT NOT NULL,
uri TEXT NOT NULL,
sha256 TEXT NOT NULL,
status TEXT NOT NULL,
content_type TEXT,
headers_json JSONB,
metadata_json JSONB,
etag TEXT,
last_modified TIMESTAMPTZ,
payload BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
CONSTRAINT pk_source_documents PRIMARY KEY (source_name, uri)
);
CREATE INDEX IF NOT EXISTS idx_source_documents_source_id ON concelier.source_documents(source_id);
CREATE INDEX IF NOT EXISTS idx_source_documents_status ON concelier.source_documents(status);

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Concelier.Storage.Postgres.Models;
public sealed record DocumentRecordEntity(
Guid Id,
Guid SourceId,
string SourceName,
string Uri,
string Sha256,
string Status,
string? ContentType,
string? HeadersJson,
string? MetadataJson,
string? Etag,
DateTimeOffset? LastModified,
byte[] Payload,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset? ExpiresAt);

View File

@@ -0,0 +1,125 @@
using System.Text.Json;
using Dapper;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Connections;
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
public interface IDocumentRepository
{
Task<DocumentRecordEntity?> FindAsync(Guid id, CancellationToken cancellationToken);
Task<DocumentRecordEntity?> FindBySourceAndUriAsync(string sourceName, string uri, CancellationToken cancellationToken);
Task<DocumentRecordEntity> UpsertAsync(DocumentRecordEntity record, CancellationToken cancellationToken);
Task UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken);
}
public sealed class DocumentRepository : RepositoryBase<ConcelierDataSource>, IDocumentRepository
{
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
public DocumentRepository(ConcelierDataSource dataSource, ILogger<DocumentRepository> logger)
: base(dataSource, logger)
{
}
public async Task<DocumentRecordEntity?> FindAsync(Guid id, CancellationToken cancellationToken)
{
const string sql = """
SELECT * FROM concelier.source_documents
WHERE id = @Id
LIMIT 1;
""";
await using var conn = await DataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await conn.QuerySingleOrDefaultAsync(sql, new { Id = id });
return row is null ? null : Map(row);
}
public async Task<DocumentRecordEntity?> FindBySourceAndUriAsync(string sourceName, string uri, CancellationToken cancellationToken)
{
const string sql = """
SELECT * FROM concelier.source_documents
WHERE source_name = @SourceName AND uri = @Uri
LIMIT 1;
""";
await using var conn = await DataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await conn.QuerySingleOrDefaultAsync(sql, new { SourceName = sourceName, Uri = uri });
return row is null ? null : Map(row);
}
public async Task<DocumentRecordEntity> UpsertAsync(DocumentRecordEntity record, CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO concelier.source_documents (
id, source_id, source_name, uri, sha256, status, content_type,
headers_json, metadata_json, etag, last_modified, payload, created_at, updated_at, expires_at)
VALUES (
@Id, @SourceId, @SourceName, @Uri, @Sha256, @Status, @ContentType,
@HeadersJson, @MetadataJson, @Etag, @LastModified, @Payload, @CreatedAt, @UpdatedAt, @ExpiresAt)
ON CONFLICT (source_name, uri) DO UPDATE SET
sha256 = EXCLUDED.sha256,
status = EXCLUDED.status,
content_type = EXCLUDED.content_type,
headers_json = EXCLUDED.headers_json,
metadata_json = EXCLUDED.metadata_json,
etag = EXCLUDED.etag,
last_modified = EXCLUDED.last_modified,
payload = EXCLUDED.payload,
updated_at = EXCLUDED.updated_at,
expires_at = EXCLUDED.expires_at
RETURNING *;
""";
await using var conn = await DataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await conn.QuerySingleAsync(sql, new
{
record.Id,
record.SourceId,
record.SourceName,
record.Uri,
record.Sha256,
record.Status,
record.ContentType,
record.HeadersJson,
record.MetadataJson,
record.Etag,
record.LastModified,
record.Payload,
record.CreatedAt,
record.UpdatedAt,
record.ExpiresAt
});
return Map(row);
}
public async Task UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken)
{
const string sql = """
UPDATE concelier.source_documents
SET status = @Status, updated_at = NOW()
WHERE id = @Id;
""";
await using var conn = await DataSource.OpenSystemConnectionAsync(cancellationToken);
await conn.ExecuteAsync(sql, new { Id = id, Status = status });
}
private DocumentRecordEntity Map(dynamic row)
{
return new DocumentRecordEntity(
row.id,
row.source_id,
row.source_name,
row.uri,
row.sha256,
row.status,
(string?)row.content_type,
(string?)row.headers_json,
(string?)row.metadata_json,
(string?)row.etag,
(DateTimeOffset?)row.last_modified,
(byte[])row.payload,
DateTime.SpecifyKind(row.created_at, DateTimeKind.Utc),
DateTime.SpecifyKind(row.updated_at, DateTimeKind.Utc),
row.expires_at is null ? null : DateTime.SpecifyKind(row.expires_at, DateTimeKind.Utc));
}
}

View File

@@ -4,6 +4,7 @@ using StellaOps.Concelier.Storage.Postgres.Repositories;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.Storage.Mongo;
namespace StellaOps.Concelier.Storage.Postgres;
@@ -38,11 +39,13 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAdvisoryWeaknessRepository, AdvisoryWeaknessRepository>();
services.AddScoped<IKevFlagRepository, KevFlagRepository>();
services.AddScoped<ISourceStateRepository, SourceStateRepository>();
services.AddScoped<IDocumentRepository, DocumentRepository>();
services.AddScoped<IFeedSnapshotRepository, FeedSnapshotRepository>();
services.AddScoped<IAdvisorySnapshotRepository, AdvisorySnapshotRepository>();
services.AddScoped<IMergeEventRepository, MergeEventRepository>();
services.AddScoped<IAdvisoryLinksetStore, AdvisoryLinksetCacheRepository>();
services.AddScoped<IAdvisoryLinksetLookup>(sp => sp.GetRequiredService<IAdvisoryLinksetStore>());
services.AddScoped<IDocumentStore, PostgresDocumentStore>();
return services;
}
@@ -71,11 +74,13 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAdvisoryWeaknessRepository, AdvisoryWeaknessRepository>();
services.AddScoped<IKevFlagRepository, KevFlagRepository>();
services.AddScoped<ISourceStateRepository, SourceStateRepository>();
services.AddScoped<IDocumentRepository, DocumentRepository>();
services.AddScoped<IFeedSnapshotRepository, FeedSnapshotRepository>();
services.AddScoped<IAdvisorySnapshotRepository, AdvisorySnapshotRepository>();
services.AddScoped<IMergeEventRepository, MergeEventRepository>();
services.AddScoped<IAdvisoryLinksetStore, AdvisoryLinksetCacheRepository>();
services.AddScoped<IAdvisoryLinksetLookup>(sp => sp.GetRequiredService<IAdvisoryLinksetStore>());
services.AddScoped<IDocumentStore, PostgresDocumentStore>();
return services;
}

View File

@@ -10,6 +10,11 @@
<RootNamespace>StellaOps.Concelier.Storage.Postgres</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
@@ -25,6 +30,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
</ItemGroup>
</Project>