wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.WebService.Security;
namespace StellaOps.Unknowns.WebService.Endpoints;
@@ -22,7 +23,8 @@ public static class GreyQueueEndpoints
public static IEndpointRouteBuilder MapGreyQueueEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/grey-queue")
.WithTags("GreyQueue");
.WithTags("GreyQueue")
.RequireAuthorization(UnknownsPolicies.Read);
// List and query
group.MapGet("/", ListEntries)
@@ -61,37 +63,43 @@ public static class GreyQueueEndpoints
.WithSummary("Get entries triggered by CVE update")
.WithDescription("Returns entries that should be reprocessed due to a CVE update.");
// Actions
// Actions (require write scope)
group.MapPost("/", EnqueueEntry)
.WithName("EnqueueGreyQueueEntry")
.WithSummary("Enqueue a new grey queue entry")
.WithDescription("Creates a new grey queue entry with evidence bundle and trigger conditions.");
.WithDescription("Creates a new grey queue entry with evidence bundle and trigger conditions.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/process", StartProcessing)
.WithName("StartGreyQueueProcessing")
.WithSummary("Mark entry as processing")
.WithDescription("Marks an entry as currently being processed.");
.WithDescription("Marks an entry as currently being processed.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/result", RecordResult)
.WithName("RecordGreyQueueResult")
.WithSummary("Record processing result")
.WithDescription("Records the result of a processing attempt.");
.WithDescription("Records the result of a processing attempt.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/resolve", ResolveEntry)
.WithName("ResolveGreyQueueEntry")
.WithSummary("Resolve a grey queue entry")
.WithDescription("Marks an entry as resolved with resolution type and reference.");
.WithDescription("Marks an entry as resolved with resolution type and reference.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/dismiss", DismissEntry)
.WithName("DismissGreyQueueEntry")
.WithSummary("Dismiss a grey queue entry")
.WithDescription("Manually dismisses an entry from the queue.");
.WithDescription("Manually dismisses an entry from the queue.")
.RequireAuthorization(UnknownsPolicies.Write);
// Maintenance
// Maintenance (require write scope)
group.MapPost("/expire", ExpireOldEntries)
.WithName("ExpireGreyQueueEntries")
.WithSummary("Expire old entries")
.WithDescription("Expires entries that have exceeded their TTL.");
.WithDescription("Expires entries that have exceeded their TTL.")
.RequireAuthorization(UnknownsPolicies.Write);
// Statistics
group.MapGet("/summary", GetSummary)
@@ -99,26 +107,30 @@ public static class GreyQueueEndpoints
.WithSummary("Get grey queue summary statistics")
.WithDescription("Returns summary counts by status, reason, and performance metrics.");
// Sprint: SPRINT_20260118_018 (UQ-005) - New state transitions
// Sprint: SPRINT_20260118_018 (UQ-005) - New state transitions (require write scope)
group.MapPost("/{id:guid}/assign", AssignForReview)
.WithName("AssignGreyQueueEntry")
.WithSummary("Assign entry for review")
.WithDescription("Assigns an entry to a reviewer, transitioning to UnderReview state.");
.WithDescription("Assigns an entry to a reviewer, transitioning to UnderReview state.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/escalate", EscalateEntry)
.WithName("EscalateGreyQueueEntry")
.WithSummary("Escalate entry to security team")
.WithDescription("Escalates an entry to the security team, transitioning to Escalated state.");
.WithDescription("Escalates an entry to the security team, transitioning to Escalated state.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/reject", RejectEntry)
.WithName("RejectGreyQueueEntry")
.WithSummary("Reject a grey queue entry")
.WithDescription("Marks an entry as rejected (invalid or not actionable).");
.WithDescription("Marks an entry as rejected (invalid or not actionable).")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/reopen", ReopenEntry)
.WithName("ReopenGreyQueueEntry")
.WithSummary("Reopen a closed entry")
.WithDescription("Reopens a rejected, failed, or dismissed entry back to pending.");
.WithDescription("Reopens a rejected, failed, or dismissed entry back to pending.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapGet("/{id:guid}/transitions", GetValidTransitions)
.WithName("GetValidTransitions")

View File

@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.WebService.Security;
namespace StellaOps.Unknowns.WebService.Endpoints;
@@ -23,7 +24,8 @@ public static class UnknownsEndpoints
public static IEndpointRouteBuilder MapUnknownsEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/unknowns")
.WithTags("Unknowns");
.WithTags("Unknowns")
.RequireAuthorization(UnknownsPolicies.Read);
// WS-004: GET /api/unknowns - List with pagination
group.MapGet("/", ListUnknowns)

View File

@@ -5,10 +5,12 @@
// Description: Entry point for Unknowns WebService with OpenAPI, health checks, auth
// -----------------------------------------------------------------------------
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Router.AspNet;
using StellaOps.Unknowns.WebService;
using StellaOps.Unknowns.WebService.Endpoints;
using StellaOps.Unknowns.WebService.Security;
var builder = WebApplication.CreateBuilder(args);
@@ -23,9 +25,13 @@ builder.Services.AddSwaggerGen();
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database");
// Authentication (placeholder - configure based on environment)
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
// Authentication and authorization
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration);
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(UnknownsPolicies.Read, StellaOpsScopes.UnknownsRead);
options.AddStellaOpsScopePolicy(UnknownsPolicies.Write, StellaOpsScopes.UnknownsWrite);
});
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
@@ -53,6 +59,7 @@ app.TryUseStellaRouter(routerEnabled);
// Map endpoints
app.MapUnknownsEndpoints();
app.MapGreyQueueEndpoints();
app.MapHealthChecks("/health");
app.TryRefreshStellaRouterEndpoints(routerEnabled);

View File

@@ -0,0 +1,16 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.Unknowns.WebService.Security;
/// <summary>
/// Named authorization policy constants for the Unknowns service.
/// Policies are registered via AddStellaOpsScopePolicy in Program.cs.
/// </summary>
internal static class UnknownsPolicies
{
/// <summary>Policy for querying and viewing unknowns and grey queue entries. Requires unknowns:read scope.</summary>
public const string Read = "Unknowns.Read";
/// <summary>Policy for classifying and mutating unknowns and grey queue entries. Requires unknowns:write scope.</summary>
public const string Write = "Unknowns.Write";
}

View File

@@ -1,16 +1,16 @@
// -----------------------------------------------------------------------------
// ServiceCollectionExtensions.cs
// Sprint: SPRINT_20260106_001_005_UNKNOWNS_provenance_hints
// Task: WS-003 - Register IUnknownRepository from PostgreSQL library
// Description: DI registration for Unknowns services
// Sprint: SPRINT_20260222_085_Unknowns_dal_to_efcore
// Task: UNKNOWN-EF-03 - Convert DAL repositories to EF Core
// Description: DI registration for Unknowns services using EF Core persistence
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.Persistence;
using StellaOps.Unknowns.Persistence.Postgres;
using StellaOps.Unknowns.Persistence.Postgres.Repositories;
namespace StellaOps.Unknowns.WebService;
@@ -22,22 +22,22 @@ public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Unknowns services to the service collection.
/// Uses EF Core via UnknownsDataSource for database access.
/// </summary>
public static IServiceCollection AddUnknownsServices(
this IServiceCollection services,
IConfiguration configuration)
{
// Register repository
var connectionString = configuration.GetConnectionString("UnknownsDb")
?? throw new InvalidOperationException("UnknownsDb connection string is required");
// Register PostgresOptions from configuration
services.Configure<PostgresOptions>(configuration.GetSection("Postgres"));
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
var dataSource = dataSourceBuilder.Build();
// Register UnknownsDataSource (manages connection pooling + tenant context)
services.AddSingleton<UnknownsDataSource>();
services.AddSingleton(dataSource);
// Register EF Core-backed repository
services.AddSingleton<IUnknownRepository>(sp =>
new PostgresUnknownRepository(
sp.GetRequiredService<NpgsqlDataSource>(),
sp.GetRequiredService<UnknownsDataSource>(),
sp.GetRequiredService<ILogger<PostgresUnknownRepository>>()));
// Register TimeProvider

View File

@@ -0,0 +1,156 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Unknowns.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Unknowns.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class UnknownEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Unknowns.Persistence.EfCore.Models.UnknownEntity",
typeof(UnknownEntity),
baseEntityType,
propertyCount: 42,
namedIndexCount: 11,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(UnknownEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UnknownEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(UnknownEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UnknownEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var subjectHash = runtimeEntityType.AddProperty(
"SubjectHash",
typeof(string),
propertyInfo: typeof(UnknownEntity).GetProperty("SubjectHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UnknownEntity).GetField("<SubjectHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
maxLength: 64);
subjectHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
subjectHash.AddAnnotation("Relational:ColumnName", "subject_hash");
subjectHash.AddAnnotation("Relational:IsFixedLength", true);
var subjectType = runtimeEntityType.AddProperty(
"SubjectType",
typeof(string),
propertyInfo: typeof(UnknownEntity).GetProperty("SubjectType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UnknownEntity).GetField("<SubjectType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
subjectType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
subjectType.AddAnnotation("Relational:ColumnName", "subject_type");
var subjectRef = runtimeEntityType.AddProperty(
"SubjectRef",
typeof(string),
propertyInfo: typeof(UnknownEntity).GetProperty("SubjectRef", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UnknownEntity).GetField("<SubjectRef>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
subjectRef.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
subjectRef.AddAnnotation("Relational:ColumnName", "subject_ref");
var kind = runtimeEntityType.AddProperty(
"Kind",
typeof(string),
propertyInfo: typeof(UnknownEntity).GetProperty("Kind", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UnknownEntity).GetField("<Kind>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
kind.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
kind.AddAnnotation("Relational:ColumnName", "kind");
var severity = runtimeEntityType.AddProperty(
"Severity",
typeof(string),
propertyInfo: typeof(UnknownEntity).GetProperty("Severity", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UnknownEntity).GetField("<Severity>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
severity.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
severity.AddAnnotation("Relational:ColumnName", "severity");
var context = runtimeEntityType.AddProperty(
"Context",
typeof(string),
propertyInfo: typeof(UnknownEntity).GetProperty("Context", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UnknownEntity).GetField("<Context>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
context.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
context.AddAnnotation("Relational:ColumnName", "context");
context.AddAnnotation("Relational:ColumnType", "jsonb");
context.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var compositeScore = runtimeEntityType.AddProperty(
"CompositeScore",
typeof(double),
propertyInfo: typeof(UnknownEntity).GetProperty("CompositeScore", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UnknownEntity).GetField("<CompositeScore>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: 0.0);
compositeScore.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
compositeScore.AddAnnotation("Relational:ColumnName", "composite_score");
compositeScore.AddAnnotation("Relational:DefaultValue", 0.0);
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(UnknownEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UnknownEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(UnknownEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UnknownEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
// Remaining properties abbreviated for compiled model stub
// Full generation should be done via `dotnet ef dbcontext optimize`
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "unknown_pkey");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "unknowns");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "unknown");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,6 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using StellaOps.Unknowns.Persistence.EfCore.CompiledModels;
using StellaOps.Unknowns.Persistence.EfCore.Context;
[assembly: DbContext(typeof(UnknownsDbContext), optimizedModel: typeof(UnknownsDbContextModel))]

View File

@@ -0,0 +1,48 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using StellaOps.Unknowns.Persistence.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Unknowns.Persistence.EfCore.CompiledModels
{
[DbContext(typeof(UnknownsDbContext))]
public partial class UnknownsDbContextModel : RuntimeModel
{
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static UnknownsDbContextModel()
{
var model = new UnknownsDbContextModel();
if (_useOldBehavior31751)
{
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
model.Customize();
_instance = (UnknownsDbContextModel)model.FinalizeModel();
}
private static UnknownsDbContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
}

View File

@@ -0,0 +1,30 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Unknowns.Persistence.EfCore.CompiledModels
{
public partial class UnknownsDbContextModel
{
private UnknownsDbContextModel()
: base(skipDetectChanges: false, modelId: new Guid("a7b3c1d2-e4f5-6789-abcd-ef0123456789"), entityTypeCount: 1)
{
}
partial void Initialize()
{
var unknownEntity = UnknownEntityEntityType.Create(this);
UnknownEntityEntityType.CreateAnnotations(unknownEntity);
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
AddAnnotation("ProductVersion", "10.0.0");
AddAnnotation("Relational:MaxIdentifierLength", 63);
}
}
}

View File

@@ -1,38 +1,180 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Infrastructure.EfCore.Context;
using StellaOps.Unknowns.Persistence.EfCore.Models;
namespace StellaOps.Unknowns.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for the Unknowns module.
/// Schema-injectable partial class following EF_CORE_MODEL_GENERATION_STANDARDS.
/// </summary>
/// <remarks>
/// This is a placeholder. Run the scaffolding script to generate the full context:
/// <code>
/// .\devops\scripts\efcore\Scaffold-Module.ps1 -Module Unknowns
/// </code>
/// </remarks>
public class UnknownsDbContext : StellaOpsDbContextBase
public partial class UnknownsDbContext : DbContext
{
/// <inheritdoc />
protected override string SchemaName => "unknowns";
private readonly string _schemaName;
/// <summary>
/// Creates a new UnknownsDbContext.
/// </summary>
public UnknownsDbContext(DbContextOptions<UnknownsDbContext> options) : base(options)
public UnknownsDbContext(DbContextOptions<UnknownsDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "unknowns"
: schemaName.Trim();
}
// DbSet properties will be generated by scaffolding:
// public virtual DbSet<Unknown> Unknowns { get; set; } = null!;
public virtual DbSet<UnknownEntity> Unknowns { get; set; }
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
var schemaName = _schemaName;
// Entity configurations will be generated by scaffolding
// For now, configure any manual customizations here
// Register PostgreSQL enum types for model awareness
modelBuilder.HasPostgresEnum(schemaName, "subject_type",
new[] { "package", "ecosystem", "version", "sbom_edge", "file", "runtime" });
modelBuilder.HasPostgresEnum(schemaName, "unknown_kind",
new[] { "missing_sbom", "ambiguous_package", "missing_feed", "unresolved_edge",
"no_version_info", "unknown_ecosystem", "partial_match",
"version_range_unbounded", "unsupported_format", "transitive_gap" });
modelBuilder.HasPostgresEnum(schemaName, "unknown_severity",
new[] { "critical", "high", "medium", "low", "info" });
modelBuilder.HasPostgresEnum(schemaName, "resolution_type",
new[] { "feed_updated", "sbom_provided", "manual_mapping", "superseded", "false_positive", "wont_fix" });
modelBuilder.HasPostgresEnum(schemaName, "triage_band",
new[] { "hot", "warm", "cold" });
modelBuilder.Entity<UnknownEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("unknown_pkey");
entity.ToTable("unknown", schemaName);
// Indexes
entity.HasIndex(e => new { e.TenantId, e.SubjectHash, e.Kind }, "uq_unknown_one_open_per_subject")
.IsUnique()
.HasFilter("valid_to IS NULL AND sys_to IS NULL");
entity.HasIndex(e => e.TenantId, "ix_unknown_tenant");
entity.HasIndex(e => new { e.TenantId, e.ValidFrom, e.ValidTo }, "ix_unknown_tenant_valid");
entity.HasIndex(e => new { e.TenantId, e.SysFrom, e.SysTo }, "ix_unknown_tenant_sys");
entity.HasIndex(e => new { e.TenantId, e.Kind, e.Severity }, "ix_unknown_tenant_kind_severity")
.HasFilter("valid_to IS NULL AND sys_to IS NULL");
entity.HasIndex(e => e.SourceScanId, "ix_unknown_source_scan")
.HasFilter("source_scan_id IS NOT NULL");
entity.HasIndex(e => e.SourceGraphId, "ix_unknown_source_graph")
.HasFilter("source_graph_id IS NOT NULL");
entity.HasIndex(e => e.SourceSbomDigest, "ix_unknown_source_sbom")
.HasFilter("source_sbom_digest IS NOT NULL");
entity.HasIndex(e => new { e.TenantId, e.SubjectRef }, "ix_unknown_subject_ref");
entity.HasIndex(e => new { e.TenantId, e.Kind, e.CreatedAt }, "ix_unknown_unresolved")
.IsDescending(false, false, true)
.HasFilter("resolved_at IS NULL AND valid_to IS NULL AND sys_to IS NULL");
// Column mappings
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.SubjectHash).HasColumnName("subject_hash").HasMaxLength(64).IsFixedLength();
entity.Property(e => e.SubjectType).HasColumnName("subject_type");
entity.Property(e => e.SubjectRef).HasColumnName("subject_ref");
entity.Property(e => e.Kind).HasColumnName("kind");
entity.Property(e => e.Severity).HasColumnName("severity");
entity.Property(e => e.Context)
.HasColumnType("jsonb")
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnName("context");
entity.Property(e => e.SourceScanId).HasColumnName("source_scan_id");
entity.Property(e => e.SourceGraphId).HasColumnName("source_graph_id");
entity.Property(e => e.SourceSbomDigest).HasColumnName("source_sbom_digest");
entity.Property(e => e.ValidFrom)
.HasDefaultValueSql("now()")
.HasColumnName("valid_from");
entity.Property(e => e.ValidTo).HasColumnName("valid_to");
entity.Property(e => e.SysFrom)
.HasDefaultValueSql("now()")
.HasColumnName("sys_from");
entity.Property(e => e.SysTo).HasColumnName("sys_to");
entity.Property(e => e.ResolvedAt).HasColumnName("resolved_at");
entity.Property(e => e.ResolutionType).HasColumnName("resolution_type");
entity.Property(e => e.ResolutionRef).HasColumnName("resolution_ref");
entity.Property(e => e.ResolutionNotes).HasColumnName("resolution_notes");
entity.Property(e => e.PopularityScore)
.HasDefaultValue(0.0)
.HasColumnName("popularity_score");
entity.Property(e => e.DeploymentCount)
.HasDefaultValue(0)
.HasColumnName("deployment_count");
entity.Property(e => e.ExploitPotentialScore)
.HasDefaultValue(0.0)
.HasColumnName("exploit_potential_score");
entity.Property(e => e.UncertaintyScore)
.HasDefaultValue(0.0)
.HasColumnName("uncertainty_score");
entity.Property(e => e.UncertaintyFlags)
.HasColumnType("jsonb")
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnName("uncertainty_flags");
entity.Property(e => e.CentralityScore)
.HasDefaultValue(0.0)
.HasColumnName("centrality_score");
entity.Property(e => e.DegreeCentrality)
.HasDefaultValue(0)
.HasColumnName("degree_centrality");
entity.Property(e => e.BetweennessCentrality)
.HasDefaultValue(0.0)
.HasColumnName("betweenness_centrality");
entity.Property(e => e.StalenessScore)
.HasDefaultValue(0.0)
.HasColumnName("staleness_score");
entity.Property(e => e.DaysSinceAnalysis)
.HasDefaultValue(0)
.HasColumnName("days_since_analysis");
entity.Property(e => e.CompositeScore)
.HasDefaultValue(0.0)
.HasColumnName("composite_score");
entity.Property(e => e.TriageBand)
.HasDefaultValue("cold")
.HasColumnName("triage_band");
entity.Property(e => e.ScoringTrace)
.HasColumnType("jsonb")
.HasColumnName("scoring_trace");
entity.Property(e => e.RescanAttempts)
.HasDefaultValue(0)
.HasColumnName("rescan_attempts");
entity.Property(e => e.LastRescanResult).HasColumnName("last_rescan_result");
entity.Property(e => e.NextScheduledRescan).HasColumnName("next_scheduled_rescan");
entity.Property(e => e.LastAnalyzedAt).HasColumnName("last_analyzed_at");
entity.Property(e => e.EvidenceSetHash).HasColumnName("evidence_set_hash");
entity.Property(e => e.GraphSliceHash).HasColumnName("graph_slice_hash");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.CreatedBy)
.HasDefaultValue("system")
.HasColumnName("created_by");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
// Provenance hint columns (migration 002)
entity.Property(e => e.ProvenanceHints)
.HasColumnType("jsonb")
.HasDefaultValueSql("'[]'::jsonb")
.HasColumnName("provenance_hints");
entity.Property(e => e.BestHypothesis).HasColumnName("best_hypothesis");
entity.Property(e => e.CombinedConfidence)
.HasColumnType("numeric(4,4)")
.HasColumnName("combined_confidence");
entity.Property(e => e.PrimarySuggestedAction).HasColumnName("primary_suggested_action");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Unknowns.Persistence.EfCore.Context;
/// <summary>
/// Design-time factory for dotnet ef CLI tooling.
/// </summary>
public sealed class UnknownsDesignTimeDbContextFactory : IDesignTimeDbContextFactory<UnknownsDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=unknowns,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_UNKNOWNS_EF_CONNECTION";
public UnknownsDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<UnknownsDbContext>()
.UseNpgsql(connectionString)
.Options;
return new UnknownsDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,91 @@
namespace StellaOps.Unknowns.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for the unknowns.unknown table.
/// Maps bitemporal unknowns with scoring, triage, and provenance hint columns.
/// </summary>
public partial class UnknownEntity
{
// Identity
public Guid Id { get; set; }
public string TenantId { get; set; } = null!;
// Subject identification
public string SubjectHash { get; set; } = null!;
public string SubjectType { get; set; } = null!;
public string SubjectRef { get; set; } = null!;
// Classification
public string Kind { get; set; } = null!;
public string? Severity { get; set; }
// Context (JSONB)
public string Context { get; set; } = "{}";
// Source correlation
public Guid? SourceScanId { get; set; }
public Guid? SourceGraphId { get; set; }
public string? SourceSbomDigest { get; set; }
// Bitemporal columns
public DateTimeOffset ValidFrom { get; set; }
public DateTimeOffset? ValidTo { get; set; }
public DateTimeOffset SysFrom { get; set; }
public DateTimeOffset? SysTo { get; set; }
// Resolution tracking
public DateTimeOffset? ResolvedAt { get; set; }
public string? ResolutionType { get; set; }
public string? ResolutionRef { get; set; }
public string? ResolutionNotes { get; set; }
// Scoring: Popularity (P)
public double PopularityScore { get; set; }
public int DeploymentCount { get; set; }
// Scoring: Exploit potential (E)
public double ExploitPotentialScore { get; set; }
// Scoring: Uncertainty density (U)
public double UncertaintyScore { get; set; }
public string UncertaintyFlags { get; set; } = "{}";
// Scoring: Centrality (C)
public double CentralityScore { get; set; }
public int DegreeCentrality { get; set; }
public double BetweennessCentrality { get; set; }
// Scoring: Staleness (S)
public double StalenessScore { get; set; }
public int DaysSinceAnalysis { get; set; }
// Scoring: Composite
public double CompositeScore { get; set; }
// Triage band
public string? TriageBand { get; set; }
// Normalization trace
public string? ScoringTrace { get; set; }
// Rescan scheduling
public int RescanAttempts { get; set; }
public string? LastRescanResult { get; set; }
public DateTimeOffset? NextScheduledRescan { get; set; }
public DateTimeOffset? LastAnalyzedAt { get; set; }
// Evidence hashes
public byte[]? EvidenceSetHash { get; set; }
public byte[]? GraphSliceHash { get; set; }
// Audit
public DateTimeOffset CreatedAt { get; set; }
public string CreatedBy { get; set; } = null!;
public DateTimeOffset UpdatedAt { get; set; }
// Provenance hints (from migration 002)
public string ProvenanceHints { get; set; } = "[]";
public string? BestHypothesis { get; set; }
public decimal? CombinedConfidence { get; set; }
public string? PrimarySuggestedAction { get; set; }
}

View File

@@ -1,33 +1,39 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.Persistence.EfCore.Context;
using StellaOps.Unknowns.Persistence.EfCore.Models;
using StellaOps.Unknowns.Persistence.Postgres;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Unknowns.Persistence.EfCore.Repositories;
/// <summary>
/// EF Core implementation of <see cref="IUnknownRepository"/>.
/// Replaces PostgresUnknownRepository (raw Npgsql) with EF Core queries.
/// Uses raw SQL via ExecuteSqlRawAsync where complex PostgreSQL-specific operations are needed.
/// </summary>
/// <remarks>
/// This is a placeholder implementation. After scaffolding, update to use the generated entities.
/// For complex queries (CTEs, window functions), use raw SQL via <see cref="UnknownsDbContext"/>.
/// </remarks>
public sealed class UnknownEfRepository : IUnknownRepository
{
private readonly UnknownsDbContext _context;
private readonly UnknownsDataSource _dataSource;
private readonly ILogger<UnknownEfRepository> _logger;
private readonly int _commandTimeoutSeconds;
/// <summary>
/// Creates a new UnknownEfRepository.
/// </summary>
public UnknownEfRepository(UnknownsDbContext context)
public UnknownEfRepository(
UnknownsDataSource dataSource,
ILogger<UnknownEfRepository> logger,
int commandTimeoutSeconds = 30)
{
_context = context;
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_commandTimeoutSeconds = commandTimeoutSeconds;
}
/// <inheritdoc />
public Task<Unknown> CreateAsync(
public async Task<Unknown> CreateAsync(
string tenantId,
UnknownSubjectType subjectType,
string subjectRef,
@@ -40,75 +46,230 @@ public sealed class UnknownEfRepository : IUnknownRepository
string createdBy,
CancellationToken cancellationToken)
{
// TODO: Implement after scaffolding generates entities
throw new NotImplementedException("Scaffold entities first: ./devops/scripts/efcore/Scaffold-Module.ps1 -Module Unknowns");
var id = Guid.NewGuid();
var subjectHash = ComputeSubjectHash(subjectRef);
var now = DateTimeOffset.UtcNow;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var entity = new UnknownEntity
{
Id = id,
TenantId = tenantId,
SubjectHash = subjectHash,
SubjectType = MapSubjectType(subjectType),
SubjectRef = subjectRef,
Kind = MapUnknownKind(kind),
Severity = severity.HasValue ? MapSeverity(severity.Value) : null,
Context = context ?? "{}",
SourceScanId = sourceScanId,
SourceGraphId = sourceGraphId,
SourceSbomDigest = sourceSbomDigest,
ValidFrom = now,
SysFrom = now,
CreatedAt = now,
CreatedBy = createdBy,
UpdatedAt = now
};
// Use raw SQL for INSERT to handle PostgreSQL enum casting
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO unknowns.unknown (
id, tenant_id, subject_hash, subject_type, subject_ref,
kind, severity, context, source_scan_id, source_graph_id, source_sbom_digest,
valid_from, sys_from, created_at, created_by, updated_at
) VALUES (
{0}, {1}, {2}, {3}::unknowns.subject_type, {4},
{5}::unknowns.unknown_kind, {6}::unknowns.unknown_severity, {7}::jsonb,
{8}, {9}, {10},
{11}, {12}, {13}, {14}, {15}
)
""",
id, tenantId, subjectHash, MapSubjectType(subjectType), subjectRef,
MapUnknownKind(kind),
severity.HasValue ? MapSeverity(severity.Value) : (object)DBNull.Value,
context ?? "{}",
sourceScanId.HasValue ? sourceScanId.Value : (object)DBNull.Value,
sourceGraphId.HasValue ? sourceGraphId.Value : (object)DBNull.Value,
(object?)sourceSbomDigest ?? DBNull.Value,
now, now, now, createdBy, now,
cancellationToken);
_logger.LogDebug("Created unknown {Id} for tenant {TenantId}, kind={Kind}", id, tenantId, kind);
return new Unknown
{
Id = id,
TenantId = tenantId,
SubjectHash = subjectHash,
SubjectType = subjectType,
SubjectRef = subjectRef,
Kind = kind,
Severity = severity,
Context = context is not null ? JsonDocument.Parse(context) : null,
SourceScanId = sourceScanId,
SourceGraphId = sourceGraphId,
SourceSbomDigest = sourceSbomDigest,
ValidFrom = now,
SysFrom = now,
CreatedAt = now,
CreatedBy = createdBy,
UpdatedAt = now
};
}
/// <inheritdoc />
public Task<Unknown?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken)
public async Task<Unknown?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken)
{
// TODO: Implement after scaffolding
throw new NotImplementedException("Scaffold entities first");
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var entity = await dbContext.Unknowns
.AsNoTracking()
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.Id == id && e.SysTo == null, cancellationToken);
return entity is not null ? MapToDomain(entity) : null;
}
/// <inheritdoc />
public Task<Unknown?> GetBySubjectHashAsync(string tenantId, string subjectHash, UnknownKind kind, CancellationToken cancellationToken)
public async Task<Unknown?> GetBySubjectHashAsync(
string tenantId,
string subjectHash,
UnknownKind kind,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
var kindStr = MapUnknownKind(kind);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var entity = await dbContext.Unknowns
.AsNoTracking()
.FirstOrDefaultAsync(e =>
e.TenantId == tenantId
&& e.SubjectHash == subjectHash
&& e.Kind == kindStr
&& e.ValidTo == null
&& e.SysTo == null,
cancellationToken);
return entity is not null ? MapToDomain(entity) : null;
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetOpenUnknownsAsync(
public async Task<IReadOnlyList<Unknown>> GetOpenUnknownsAsync(
string tenantId,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var query = dbContext.Unknowns
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.ValidTo == null && e.SysTo == null)
.OrderByDescending(e => e.CreatedAt);
IQueryable<UnknownEntity> paged = query;
if (offset.HasValue)
paged = paged.Skip(offset.Value);
if (limit.HasValue)
paged = paged.Take(limit.Value);
var entities = await paged.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetByKindAsync(
public async Task<IReadOnlyList<Unknown>> GetByKindAsync(
string tenantId,
UnknownKind kind,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
var kindStr = MapUnknownKind(kind);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var query = dbContext.Unknowns
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.Kind == kindStr && e.ValidTo == null && e.SysTo == null)
.OrderByDescending(e => e.CreatedAt);
IQueryable<UnknownEntity> limited = query;
if (limit.HasValue)
limited = limited.Take(limit.Value);
var entities = await limited.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetBySeverityAsync(
public async Task<IReadOnlyList<Unknown>> GetBySeverityAsync(
string tenantId,
UnknownSeverity severity,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
var severityStr = MapSeverity(severity);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var query = dbContext.Unknowns
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.Severity == severityStr && e.ValidTo == null && e.SysTo == null)
.OrderByDescending(e => e.CreatedAt);
IQueryable<UnknownEntity> limited = query;
if (limit.HasValue)
limited = limited.Take(limit.Value);
var entities = await limited.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetByScanIdAsync(
public async Task<IReadOnlyList<Unknown>> GetByScanIdAsync(
string tenantId,
Guid scanId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var entities = await dbContext.Unknowns
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.SourceScanId == scanId && e.SysTo == null)
.OrderByDescending(e => e.CreatedAt)
.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> AsOfAsync(
public async Task<IReadOnlyList<Unknown>> AsOfAsync(
string tenantId,
DateTimeOffset validAt,
DateTimeOffset? systemAt = null,
CancellationToken cancellationToken = default)
{
// Bitemporal query - will use raw SQL for efficiency after scaffolding
throw new NotImplementedException("Scaffold entities first");
var sysAt = systemAt ?? DateTimeOffset.UtcNow;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var entities = await dbContext.Unknowns
.AsNoTracking()
.Where(e => e.TenantId == tenantId
&& e.ValidFrom <= validAt
&& (e.ValidTo == null || e.ValidTo > validAt)
&& e.SysFrom <= sysAt
&& (e.SysTo == null || e.SysTo > sysAt))
.OrderByDescending(e => e.CreatedAt)
.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
/// <inheritdoc />
public Task<Unknown> ResolveAsync(
public async Task<Unknown> ResolveAsync(
string tenantId,
Guid id,
ResolutionType resolutionType,
@@ -117,74 +278,183 @@ public sealed class UnknownEfRepository : IUnknownRepository
string resolvedBy,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
var now = DateTimeOffset.UtcNow;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var affected = await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE unknowns.unknown
SET resolved_at = {0},
resolution_type = {1}::unknowns.resolution_type,
resolution_ref = {2},
resolution_notes = {3},
valid_to = {4},
updated_at = {5}
WHERE tenant_id = {6}
AND id = {7}
AND sys_to IS NULL
""",
now,
MapResolutionType(resolutionType),
(object?)resolutionRef ?? DBNull.Value,
(object?)resolutionNotes ?? DBNull.Value,
now,
now,
tenantId,
id,
cancellationToken);
if (affected == 0)
{
throw new InvalidOperationException($"Unknown {id} not found or already superseded.");
}
_logger.LogInformation("Resolved unknown {Id} with type {ResolutionType}", id, resolutionType);
var resolved = await GetByIdAsync(tenantId, id, cancellationToken);
return resolved ?? throw new InvalidOperationException($"Failed to retrieve resolved unknown {id}.");
}
/// <inheritdoc />
public Task SupersedeAsync(
public async Task SupersedeAsync(
string tenantId,
Guid id,
string supersededBy,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
var now = DateTimeOffset.UtcNow;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var affected = await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE unknowns.unknown
SET sys_to = {0},
updated_at = {1}
WHERE tenant_id = {2}
AND id = {3}
AND sys_to IS NULL
""",
now, now, tenantId, id,
cancellationToken);
if (affected == 0)
{
throw new InvalidOperationException($"Unknown {id} not found or already superseded.");
}
_logger.LogDebug("Superseded unknown {Id} by {SupersededBy}", id, supersededBy);
}
/// <inheritdoc />
public Task<IReadOnlyDictionary<UnknownKind, long>> CountByKindAsync(
public async Task<IReadOnlyDictionary<UnknownKind, long>> CountByKindAsync(
string tenantId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var groups = await dbContext.Unknowns
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.ValidTo == null && e.SysTo == null)
.GroupBy(e => e.Kind)
.Select(g => new { Kind = g.Key, Count = g.LongCount() })
.ToListAsync(cancellationToken);
return groups.ToDictionary(g => ParseUnknownKind(g.Kind), g => g.Count);
}
/// <inheritdoc />
public Task<IReadOnlyDictionary<UnknownSeverity, long>> CountBySeverityAsync(
public async Task<IReadOnlyDictionary<UnknownSeverity, long>> CountBySeverityAsync(
string tenantId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var groups = await dbContext.Unknowns
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.ValidTo == null && e.SysTo == null && e.Severity != null)
.GroupBy(e => e.Severity!)
.Select(g => new { Severity = g.Key, Count = g.LongCount() })
.ToListAsync(cancellationToken);
return groups.ToDictionary(g => ParseSeverity(g.Severity), g => g.Count);
}
/// <inheritdoc />
public Task<long> CountOpenAsync(string tenantId, CancellationToken cancellationToken)
public async Task<long> CountOpenAsync(string tenantId, CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
return await dbContext.Unknowns
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.ValidTo == null && e.SysTo == null)
.LongCountAsync(cancellationToken);
}
// Triage methods
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetByTriageBandAsync(
public async Task<IReadOnlyList<Unknown>> GetByTriageBandAsync(
string tenantId,
TriageBand band,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
var bandStr = MapTriageBand(band);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var query = dbContext.Unknowns
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.TriageBand == bandStr && e.ValidTo == null && e.SysTo == null)
.OrderByDescending(e => e.CompositeScore);
IQueryable<UnknownEntity> paged = query;
if (offset.HasValue)
paged = paged.Skip(offset.Value);
if (limit.HasValue)
paged = paged.Take(limit.Value);
var entities = await paged.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetHotQueueAsync(
public async Task<IReadOnlyList<Unknown>> GetHotQueueAsync(
string tenantId,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
return await GetByTriageBandAsync(tenantId, TriageBand.Hot, limit, null, cancellationToken);
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetDueForRescanAsync(
public async Task<IReadOnlyList<Unknown>> GetDueForRescanAsync(
string tenantId,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
var now = DateTimeOffset.UtcNow;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var query = dbContext.Unknowns
.AsNoTracking()
.Where(e => e.TenantId == tenantId
&& e.NextScheduledRescan <= now
&& e.ValidTo == null
&& e.SysTo == null)
.OrderBy(e => e.NextScheduledRescan);
IQueryable<UnknownEntity> limited = query;
if (limit.HasValue)
limited = limited.Take(limit.Value);
var entities = await limited.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
/// <inheritdoc />
public Task<Unknown> UpdateScoresAsync(
public async Task<Unknown> UpdateScoresAsync(
string tenantId,
Guid id,
double popularityScore,
@@ -203,37 +473,148 @@ public sealed class UnknownEfRepository : IUnknownRepository
DateTimeOffset? nextScheduledRescan,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
var now = DateTimeOffset.UtcNow;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var affected = await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE unknowns.unknown
SET popularity_score = {0},
deployment_count = {1},
exploit_potential_score = {2},
uncertainty_score = {3},
uncertainty_flags = {4}::jsonb,
centrality_score = {5},
degree_centrality = {6},
betweenness_centrality = {7},
staleness_score = {8},
days_since_analysis = {9},
composite_score = {10},
triage_band = {11}::unknowns.triage_band,
scoring_trace = {12}::jsonb,
next_scheduled_rescan = {13},
last_analyzed_at = {14},
updated_at = {15}
WHERE tenant_id = {16}
AND id = {17}
AND sys_to IS NULL
""",
popularityScore, deploymentCount, exploitPotentialScore, uncertaintyScore,
uncertaintyFlags ?? "{}",
centralityScore, degreeCentrality, betweennessCentrality,
stalenessScore, daysSinceAnalysis, compositeScore,
MapTriageBand(triageBand),
scoringTrace ?? "{}",
nextScheduledRescan.HasValue ? nextScheduledRescan.Value : (object)DBNull.Value,
now, now, tenantId, id,
cancellationToken);
if (affected == 0)
{
throw new InvalidOperationException($"Unknown {id} not found or already superseded.");
}
_logger.LogDebug("Updated scores for unknown {Id}, band={Band}, score={Score}", id, triageBand, compositeScore);
var updated = await GetByIdAsync(tenantId, id, cancellationToken);
return updated ?? throw new InvalidOperationException($"Failed to retrieve updated unknown {id}.");
}
/// <inheritdoc />
public Task<Unknown> RecordRescanAttemptAsync(
public async Task<Unknown> RecordRescanAttemptAsync(
string tenantId,
Guid id,
string result,
DateTimeOffset? nextRescan,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
var now = DateTimeOffset.UtcNow;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var affected = await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE unknowns.unknown
SET rescan_attempts = rescan_attempts + 1,
last_rescan_result = {0},
next_scheduled_rescan = {1},
updated_at = {2}
WHERE tenant_id = {3}
AND id = {4}
AND sys_to IS NULL
""",
result,
nextRescan.HasValue ? nextRescan.Value : (object)DBNull.Value,
now, tenantId, id,
cancellationToken);
if (affected == 0)
{
throw new InvalidOperationException($"Unknown {id} not found or already superseded.");
}
_logger.LogDebug("Recorded rescan attempt for unknown {Id}, result={Result}", id, result);
var updated = await GetByIdAsync(tenantId, id, cancellationToken);
return updated ?? throw new InvalidOperationException($"Failed to retrieve updated unknown {id}.");
}
/// <inheritdoc />
public Task<IReadOnlyDictionary<TriageBand, long>> CountByTriageBandAsync(
public async Task<IReadOnlyDictionary<TriageBand, long>> CountByTriageBandAsync(
string tenantId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var groups = await dbContext.Unknowns
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.ValidTo == null && e.SysTo == null)
.GroupBy(e => e.TriageBand)
.Select(g => new { Band = g.Key, Count = g.LongCount() })
.ToListAsync(cancellationToken);
return groups.ToDictionary(
g => ParseTriageBand(g.Band ?? "cold"),
g => g.Count);
}
/// <inheritdoc />
public Task<IReadOnlyList<TriageSummary>> GetTriageSummaryAsync(
public async Task<IReadOnlyList<TriageSummary>> GetTriageSummaryAsync(
string tenantId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
await using var dbContext = UnknownsDbContextFactory.Create(connection, _commandTimeoutSeconds, GetSchemaName());
var groups = await dbContext.Unknowns
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.ValidTo == null && e.SysTo == null)
.GroupBy(e => new { e.TriageBand, e.Kind })
.Select(g => new
{
Band = g.Key.TriageBand,
Kind = g.Key.Kind,
Count = g.LongCount(),
AvgScore = g.Average(e => e.CompositeScore),
MaxScore = g.Max(e => e.CompositeScore),
MinScore = g.Min(e => e.CompositeScore)
})
.OrderBy(g => g.Band)
.ThenBy(g => g.Kind)
.ToListAsync(cancellationToken);
return groups.Select(g => new TriageSummary
{
Band = ParseTriageBand(g.Band ?? "cold"),
Kind = ParseUnknownKind(g.Kind),
Count = g.Count,
AvgScore = g.AvgScore,
MaxScore = g.MaxScore,
MinScore = g.MinScore
}).ToList();
}
/// <inheritdoc />
public Task<Unknown> AttachProvenanceHintsAsync(
string tenantId,
Guid id,
@@ -243,16 +624,187 @@ public sealed class UnknownEfRepository : IUnknownRepository
string? primarySuggestedAction,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
// TODO: Implement provenance hints storage when migration 002 table name discrepancy is resolved
throw new NotImplementedException("Provenance hints storage not yet implemented");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetWithHighConfidenceHintsAsync(
string tenantId,
double minConfidence = 0.7,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
// TODO: Implement provenance hints query when migration 002 table name discrepancy is resolved
throw new NotImplementedException("Provenance hints query not yet implemented");
}
private string GetSchemaName() => UnknownsDataSource.DefaultSchemaName;
// --- Mapping helpers ---
private static Unknown MapToDomain(UnknownEntity entity)
{
return new Unknown
{
Id = entity.Id,
TenantId = entity.TenantId,
SubjectHash = entity.SubjectHash,
SubjectType = ParseSubjectType(entity.SubjectType),
SubjectRef = entity.SubjectRef,
Kind = ParseUnknownKind(entity.Kind),
Severity = entity.Severity is not null ? ParseSeverity(entity.Severity) : null,
Context = !string.IsNullOrEmpty(entity.Context) && entity.Context != "{}" ? JsonDocument.Parse(entity.Context) : null,
SourceScanId = entity.SourceScanId,
SourceGraphId = entity.SourceGraphId,
SourceSbomDigest = entity.SourceSbomDigest,
ValidFrom = entity.ValidFrom,
ValidTo = entity.ValidTo,
SysFrom = entity.SysFrom,
SysTo = entity.SysTo,
ResolvedAt = entity.ResolvedAt,
ResolutionType = entity.ResolutionType is not null ? ParseResolutionType(entity.ResolutionType) : null,
ResolutionRef = entity.ResolutionRef,
ResolutionNotes = entity.ResolutionNotes,
CreatedAt = entity.CreatedAt,
CreatedBy = entity.CreatedBy,
UpdatedAt = entity.UpdatedAt,
PopularityScore = entity.PopularityScore,
DeploymentCount = entity.DeploymentCount,
ExploitPotentialScore = entity.ExploitPotentialScore,
UncertaintyScore = entity.UncertaintyScore,
UncertaintyFlags = !string.IsNullOrEmpty(entity.UncertaintyFlags) && entity.UncertaintyFlags != "{}"
? JsonDocument.Parse(entity.UncertaintyFlags) : null,
CentralityScore = entity.CentralityScore,
DegreeCentrality = entity.DegreeCentrality,
BetweennessCentrality = entity.BetweennessCentrality,
StalenessScore = entity.StalenessScore,
DaysSinceAnalysis = entity.DaysSinceAnalysis,
CompositeScore = entity.CompositeScore,
TriageBand = entity.TriageBand is not null ? ParseTriageBand(entity.TriageBand) : TriageBand.Cold,
ScoringTrace = !string.IsNullOrEmpty(entity.ScoringTrace) ? JsonDocument.Parse(entity.ScoringTrace) : null,
RescanAttempts = entity.RescanAttempts,
LastRescanResult = entity.LastRescanResult,
NextScheduledRescan = entity.NextScheduledRescan,
LastAnalyzedAt = entity.LastAnalyzedAt,
EvidenceSetHash = entity.EvidenceSetHash,
GraphSliceHash = entity.GraphSliceHash
};
}
private static string ComputeSubjectHash(string subjectRef)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(subjectRef));
return Convert.ToHexStringLower(bytes);
}
// Enum mapping helpers (string <-> domain enum)
private static string MapSubjectType(UnknownSubjectType type) => type switch
{
UnknownSubjectType.Package => "package",
UnknownSubjectType.Ecosystem => "ecosystem",
UnknownSubjectType.Version => "version",
UnknownSubjectType.SbomEdge => "sbom_edge",
UnknownSubjectType.File => "file",
UnknownSubjectType.Runtime => "runtime",
_ => throw new ArgumentOutOfRangeException(nameof(type))
};
private static UnknownSubjectType ParseSubjectType(string value) => value switch
{
"package" => UnknownSubjectType.Package,
"ecosystem" => UnknownSubjectType.Ecosystem,
"version" => UnknownSubjectType.Version,
"sbom_edge" => UnknownSubjectType.SbomEdge,
"file" => UnknownSubjectType.File,
"runtime" => UnknownSubjectType.Runtime,
_ => throw new ArgumentOutOfRangeException(nameof(value))
};
private static string MapUnknownKind(UnknownKind kind) => kind switch
{
UnknownKind.MissingSbom => "missing_sbom",
UnknownKind.AmbiguousPackage => "ambiguous_package",
UnknownKind.MissingFeed => "missing_feed",
UnknownKind.UnresolvedEdge => "unresolved_edge",
UnknownKind.NoVersionInfo => "no_version_info",
UnknownKind.UnknownEcosystem => "unknown_ecosystem",
UnknownKind.PartialMatch => "partial_match",
UnknownKind.VersionRangeUnbounded => "version_range_unbounded",
UnknownKind.UnsupportedFormat => "unsupported_format",
UnknownKind.TransitiveGap => "transitive_gap",
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
private static UnknownKind ParseUnknownKind(string value) => value switch
{
"missing_sbom" => UnknownKind.MissingSbom,
"ambiguous_package" => UnknownKind.AmbiguousPackage,
"missing_feed" => UnknownKind.MissingFeed,
"unresolved_edge" => UnknownKind.UnresolvedEdge,
"no_version_info" => UnknownKind.NoVersionInfo,
"unknown_ecosystem" => UnknownKind.UnknownEcosystem,
"partial_match" => UnknownKind.PartialMatch,
"version_range_unbounded" => UnknownKind.VersionRangeUnbounded,
"unsupported_format" => UnknownKind.UnsupportedFormat,
"transitive_gap" => UnknownKind.TransitiveGap,
_ => throw new ArgumentOutOfRangeException(nameof(value))
};
private static string MapSeverity(UnknownSeverity severity) => severity switch
{
UnknownSeverity.Critical => "critical",
UnknownSeverity.High => "high",
UnknownSeverity.Medium => "medium",
UnknownSeverity.Low => "low",
UnknownSeverity.Info => "info",
_ => throw new ArgumentOutOfRangeException(nameof(severity))
};
private static UnknownSeverity ParseSeverity(string value) => value switch
{
"critical" => UnknownSeverity.Critical,
"high" => UnknownSeverity.High,
"medium" => UnknownSeverity.Medium,
"low" => UnknownSeverity.Low,
"info" => UnknownSeverity.Info,
_ => throw new ArgumentOutOfRangeException(nameof(value))
};
private static string MapResolutionType(ResolutionType type) => type switch
{
ResolutionType.FeedUpdated => "feed_updated",
ResolutionType.SbomProvided => "sbom_provided",
ResolutionType.ManualMapping => "manual_mapping",
ResolutionType.Superseded => "superseded",
ResolutionType.FalsePositive => "false_positive",
ResolutionType.WontFix => "wont_fix",
_ => throw new ArgumentOutOfRangeException(nameof(type))
};
private static ResolutionType ParseResolutionType(string value) => value switch
{
"feed_updated" => ResolutionType.FeedUpdated,
"sbom_provided" => ResolutionType.SbomProvided,
"manual_mapping" => ResolutionType.ManualMapping,
"superseded" => ResolutionType.Superseded,
"false_positive" => ResolutionType.FalsePositive,
"wont_fix" => ResolutionType.WontFix,
_ => throw new ArgumentOutOfRangeException(nameof(value))
};
private static string MapTriageBand(TriageBand band) => band switch
{
TriageBand.Hot => "hot",
TriageBand.Warm => "warm",
TriageBand.Cold => "cold",
_ => throw new ArgumentOutOfRangeException(nameof(band))
};
private static TriageBand ParseTriageBand(string value) => value switch
{
"hot" => TriageBand.Hot,
"warm" => TriageBand.Warm,
"cold" => TriageBand.Cold,
_ => throw new ArgumentOutOfRangeException(nameof(value))
};
}

View File

@@ -1,5 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using StellaOps.Infrastructure.EfCore.Extensions;
using StellaOps.Infrastructure.EfCore.Tenancy;
using StellaOps.Unknowns.Core.Persistence;
@@ -7,21 +6,12 @@ using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.Persistence.EfCore.Context;
using StellaOps.Unknowns.Persistence.EfCore.Repositories;
using StellaOps.Unknowns.Persistence.Postgres;
using StellaOps.Unknowns.Persistence.Postgres.Repositories;
namespace StellaOps.Unknowns.Persistence.Extensions;
/// <summary>
/// Extension methods for registering Unknowns persistence services.
/// </summary>
/// <remarks>
/// Provides three persistence strategies:
/// <list type="bullet">
/// <item><see cref="AddUnknownsPersistence"/> - EF Core (recommended)</item>
/// <item><see cref="AddUnknownsPersistenceRawSql"/> - Raw SQL for complex queries</item>
/// <item><see cref="AddUnknownsPersistenceInMemory"/> - In-memory for testing</item>
/// </list>
/// </remarks>
public static class UnknownsPersistenceExtensions
{
private const string SchemaName = "unknowns";
@@ -29,9 +19,6 @@ public static class UnknownsPersistenceExtensions
/// <summary>
/// Registers EF Core persistence for the Unknowns module (recommended).
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddUnknownsPersistence(
this IServiceCollection services,
string connectionString)
@@ -54,21 +41,11 @@ public static class UnknownsPersistenceExtensions
/// Registers EF Core persistence with compiled model for faster startup.
/// Use this overload for production deployments.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddUnknownsPersistenceWithCompiledModel(
this IServiceCollection services,
string connectionString)
{
// Register DbContext with compiled model and tenant isolation
// Uncomment when compiled models are generated:
// services.AddStellaOpsDbContextWithCompiledModel<UnknownsDbContext, CompiledModels.UnknownsDbContextModel>(
// connectionString,
// SchemaName,
// CompiledModels.UnknownsDbContextModel.Instance);
// For now, use standard registration
// Register DbContext with tenant isolation
services.AddStellaOpsDbContext<UnknownsDbContext>(
connectionString,
SchemaName);
@@ -82,48 +59,6 @@ public static class UnknownsPersistenceExtensions
return services;
}
/// <summary>
/// Registers raw SQL persistence for the Unknowns module.
/// Use for complex queries (CTEs, window functions) or during migration period.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddUnknownsPersistenceRawSql(
this IServiceCollection services,
string connectionString)
{
// Register NpgsqlDataSource for raw SQL access
services.AddSingleton<NpgsqlDataSource>(_ =>
{
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
return dataSourceBuilder.Build();
});
// Register raw SQL repository implementations
services.AddScoped<IUnknownRepository, PostgresUnknownRepository>();
// Register persister
services.AddScoped<IUnknownPersister, PostgresUnknownPersister>();
return services;
}
/// <summary>
/// Registers in-memory persistence for testing.
/// </summary>
/// <param name="services">Service collection.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddUnknownsPersistenceInMemory(
this IServiceCollection services)
{
// TODO: Implement in-memory repositories for testing
// services.AddSingleton<IUnknownRepository, InMemoryUnknownRepository>();
// services.AddSingleton<IUnknownPersister, InMemoryUnknownPersister>();
throw new NotImplementedException("In-memory persistence not yet implemented. Use AddUnknownsPersistenceRawSql for testing.");
}
/// <summary>
/// Registers a fallback tenant context accessor that always uses "_system".
/// Use for worker services or migrations.

View File

@@ -0,0 +1,44 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Unknowns.Persistence.Postgres;
/// <summary>
/// PostgreSQL data source for the Unknowns module.
/// </summary>
public sealed class UnknownsDataSource : DataSourceBase
{
/// <summary>
/// Default schema name for Unknowns tables.
/// </summary>
public const string DefaultSchemaName = "unknowns";
/// <summary>
/// Creates a new Unknowns data source.
/// </summary>
public UnknownsDataSource(IOptions<PostgresOptions> options, ILogger<UnknownsDataSource> logger)
: base(CreateOptions(options.Value), logger)
{
}
/// <inheritdoc />
protected override string ModuleName => "Unknowns";
/// <inheritdoc />
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
base.ConfigureDataSourceBuilder(builder);
}
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Unknowns.Persistence.EfCore.CompiledModels;
using StellaOps.Unknowns.Persistence.EfCore.Context;
namespace StellaOps.Unknowns.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating UnknownsDbContext instances.
/// Uses compiled model for default schema, reflection-based model for non-default schemas.
/// </summary>
internal static class UnknownsDbContextFactory
{
public static UnknownsDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? UnknownsDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<UnknownsDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, UnknownsDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model when schema matches the default.
optionsBuilder.UseModel(UnknownsDbContextModel.Instance);
}
return new UnknownsDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -30,4 +30,9 @@
<EmbeddedResource Include="Migrations\**\*.sql" />
</ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<ItemGroup>
<Compile Remove="EfCore\CompiledModels\UnknownsDbContextAssemblyAttributes.cs" />
</ItemGroup>
</Project>

View File

@@ -1,7 +1,10 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Persistence.Postgres;
using StellaOps.Unknowns.Persistence.Postgres.Repositories;
using Testcontainers.PostgreSql;
using Xunit;
@@ -16,7 +19,7 @@ public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
.WithImage("postgres:16")
.Build();
private NpgsqlDataSource _dataSource = null!;
private UnknownsDataSource _dataSource = null!;
private PostgresUnknownRepository _repository = null!;
private const string TestTenantId = "test-tenant";
@@ -25,10 +28,17 @@ public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
await _postgres.StartAsync();
var connectionString = _postgres.GetConnectionString();
_dataSource = NpgsqlDataSource.Create(connectionString);
// Run schema migrations
await RunMigrationsAsync();
// Run schema migrations using a raw NpgsqlDataSource
await RunMigrationsAsync(connectionString);
// Create the UnknownsDataSource with PostgresOptions
var options = Options.Create(new PostgresOptions
{
ConnectionString = connectionString,
SchemaName = "unknowns"
});
_dataSource = new UnknownsDataSource(options, NullLogger<UnknownsDataSource>.Instance);
_repository = new PostgresUnknownRepository(
_dataSource,
@@ -41,9 +51,10 @@ public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
await _postgres.DisposeAsync();
}
private async Task RunMigrationsAsync()
private static async Task RunMigrationsAsync(string connectionString)
{
await using var connection = await _dataSource.OpenConnectionAsync();
var rawDataSource = NpgsqlDataSource.Create(connectionString);
await using var connection = await rawDataSource.OpenConnectionAsync();
// Create schema and types
const string schema = """
@@ -153,6 +164,8 @@ public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
await using var command = new NpgsqlCommand(schema, connection);
await command.ExecuteNonQueryAsync();
await rawDataSource.DisposeAsync();
}
[Trait("Category", TestCategories.Unit)]