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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user