Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Infrastructure.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Unknowns.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the Unknowns module.
|
||||
/// </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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override string SchemaName => "unknowns";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new UnknownsDbContext.
|
||||
/// </summary>
|
||||
public UnknownsDbContext(DbContextOptions<UnknownsDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
// DbSet properties will be generated by scaffolding:
|
||||
// public virtual DbSet<Unknown> Unknowns { get; set; } = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Entity configurations will be generated by scaffolding
|
||||
// For now, configure any manual customizations here
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Infrastructure.EfCore.Extensions;
|
||||
using StellaOps.Infrastructure.EfCore.Tenancy;
|
||||
using StellaOps.Unknowns.Core.Repositories;
|
||||
using StellaOps.Unknowns.Persistence.EfCore.Repositories;
|
||||
|
||||
namespace StellaOps.Unknowns.Persistence.EfCore.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering Unknowns EF Core persistence.
|
||||
/// </summary>
|
||||
public static class UnknownsPersistenceExtensions
|
||||
{
|
||||
private const string SchemaName = "unknowns";
|
||||
|
||||
/// <summary>
|
||||
/// Registers EF Core persistence for the Unknowns module.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="connectionString">PostgreSQL connection string.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddUnknownsEfCorePersistence(
|
||||
this IServiceCollection services,
|
||||
string connectionString)
|
||||
{
|
||||
// Register DbContext with tenant isolation
|
||||
services.AddStellaOpsDbContext<Context.UnknownsDbContext>(
|
||||
connectionString,
|
||||
SchemaName);
|
||||
|
||||
// Register repository implementations
|
||||
services.AddScoped<IUnknownRepository, UnknownEfRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers EF Core persistence for the Unknowns module with compiled model.
|
||||
/// Use this overload for production deployments for faster startup.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="connectionString">PostgreSQL connection string.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddUnknownsEfCorePersistenceWithCompiledModel(
|
||||
this IServiceCollection services,
|
||||
string connectionString)
|
||||
{
|
||||
// Register DbContext with compiled model and tenant isolation
|
||||
// Uncomment when compiled models are generated:
|
||||
// services.AddStellaOpsDbContextWithCompiledModel<Context.UnknownsDbContext, CompiledModels.UnknownsDbContextModel>(
|
||||
// connectionString,
|
||||
// SchemaName,
|
||||
// CompiledModels.UnknownsDbContextModel.Instance);
|
||||
|
||||
// For now, use standard registration
|
||||
services.AddStellaOpsDbContext<Context.UnknownsDbContext>(
|
||||
connectionString,
|
||||
SchemaName);
|
||||
|
||||
// Register repository implementations
|
||||
services.AddScoped<IUnknownRepository, UnknownEfRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a fallback tenant context accessor that always uses "_system".
|
||||
/// Use for worker services or migrations.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddUnknownsSystemTenantContext(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ITenantContextAccessor>(SystemTenantContextAccessor.Instance);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# StellaOps.Unknowns.Persistence.EfCore
|
||||
|
||||
EF Core persistence layer for the Unknowns module using database-first scaffolding.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
├── Context/
|
||||
│ └── UnknownsDbContext.cs # Scaffolded from database
|
||||
├── Entities/
|
||||
│ └── *.cs # Scaffolded from database
|
||||
├── CompiledModels/
|
||||
│ └── *.cs # Generated for performance
|
||||
├── Repositories/
|
||||
│ └── UnknownEfRepository.cs # Repository implementation
|
||||
└── Extensions/
|
||||
└── UnknownsPersistenceExtensions.cs # DI registration
|
||||
```
|
||||
|
||||
## Scaffolding
|
||||
|
||||
To scaffold/regenerate from the database:
|
||||
|
||||
```powershell
|
||||
# From repository root
|
||||
.\devops\scripts\efcore\Scaffold-Module.ps1 -Module Unknowns
|
||||
```
|
||||
|
||||
Or on Linux/macOS:
|
||||
|
||||
```bash
|
||||
./devops/scripts/efcore/scaffold-module.sh Unknowns
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. PostgreSQL running with `unknowns` schema initialized:
|
||||
```bash
|
||||
docker compose -f devops/compose/docker-compose.dev.yaml up -d postgres
|
||||
```
|
||||
|
||||
2. Migrations applied:
|
||||
```bash
|
||||
dotnet run --project src/Unknowns/__Libraries/StellaOps.Unknowns.Storage.Postgres -- migrate
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Register in `Program.cs`:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddUnknownsEfCorePersistence(
|
||||
connectionString: configuration.GetConnectionString("Unknowns")!);
|
||||
```
|
||||
@@ -0,0 +1,291 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Core.Repositories;
|
||||
using StellaOps.Unknowns.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Unknowns.Persistence.EfCore.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IUnknownRepository"/>.
|
||||
/// </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.RawSqlQueryAsync{T}"/>.
|
||||
/// </remarks>
|
||||
public sealed class UnknownEfRepository : IUnknownRepository
|
||||
{
|
||||
private readonly UnknownsDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new UnknownEfRepository.
|
||||
/// </summary>
|
||||
public UnknownEfRepository(UnknownsDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Unknown> CreateAsync(
|
||||
string tenantId,
|
||||
UnknownSubjectType subjectType,
|
||||
string subjectRef,
|
||||
UnknownKind kind,
|
||||
UnknownSeverity? severity,
|
||||
string? context,
|
||||
Guid? sourceScanId,
|
||||
Guid? sourceGraphId,
|
||||
string? sourceSbomDigest,
|
||||
string createdBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Implement after scaffolding generates entities
|
||||
throw new NotImplementedException("Scaffold entities first: ./devops/scripts/efcore/Scaffold-Module.ps1 -Module Unknowns");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Unknown?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Implement after scaffolding
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Unknown?> GetBySubjectHashAsync(string tenantId, string subjectHash, UnknownKind kind, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetOpenUnknownsAsync(
|
||||
string tenantId,
|
||||
int? limit = null,
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetByKindAsync(
|
||||
string tenantId,
|
||||
UnknownKind kind,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetBySeverityAsync(
|
||||
string tenantId,
|
||||
UnknownSeverity severity,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetByScanIdAsync(
|
||||
string tenantId,
|
||||
Guid scanId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Unknown>> AsOfAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset validAt,
|
||||
DateTimeOffset? systemAt = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Bitemporal query - use raw SQL for efficiency
|
||||
var sysAt = systemAt ?? DateTimeOffset.UtcNow;
|
||||
|
||||
// This is a complex query that benefits from raw SQL
|
||||
var results = await _context.RawSqlQueryAsync<UnknownDto>($"""
|
||||
SELECT * FROM unknowns.unknown
|
||||
WHERE tenant_id = {tenantId}
|
||||
AND valid_from <= {validAt}
|
||||
AND (valid_to IS NULL OR valid_to > {validAt})
|
||||
AND sys_from <= {sysAt}
|
||||
AND (sys_to IS NULL OR sys_to > {sysAt})
|
||||
ORDER BY created_at DESC
|
||||
""", cancellationToken);
|
||||
|
||||
return results.Select(MapToModel).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Unknown> ResolveAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
ResolutionType resolutionType,
|
||||
string? resolutionRef,
|
||||
string? resolutionNotes,
|
||||
string resolvedBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SupersedeAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string supersededBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<UnknownKind, long>> CountByKindAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Use raw SQL for grouping
|
||||
var results = await _context.RawSqlQueryAsync<KindCount>($"""
|
||||
SELECT kind, count(*) as count
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = {tenantId}
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
GROUP BY kind
|
||||
""", cancellationToken);
|
||||
|
||||
return results.ToDictionary(
|
||||
r => Enum.Parse<UnknownKind>(r.Kind, ignoreCase: true),
|
||||
r => r.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<UnknownSeverity, long>> CountBySeverityAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = await _context.RawSqlQueryAsync<SeverityCount>($"""
|
||||
SELECT severity, count(*) as count
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = {tenantId}
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
AND severity IS NOT NULL
|
||||
GROUP BY severity
|
||||
""", cancellationToken);
|
||||
|
||||
return results.ToDictionary(
|
||||
r => Enum.Parse<UnknownSeverity>(r.Severity, ignoreCase: true),
|
||||
r => r.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<long> CountOpenAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _context.RawSqlQueryAsync<CountResult>($"""
|
||||
SELECT count(*) as count
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = {tenantId}
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
""", cancellationToken);
|
||||
|
||||
return result.FirstOrDefault()?.Count ?? 0;
|
||||
}
|
||||
|
||||
// Triage methods
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetByTriageBandAsync(
|
||||
string tenantId,
|
||||
TriageBand band,
|
||||
int? limit = null,
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetHotQueueAsync(
|
||||
string tenantId,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetDueForRescanAsync(
|
||||
string tenantId,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Unknown> UpdateScoresAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
double popularityScore,
|
||||
int deploymentCount,
|
||||
double exploitPotentialScore,
|
||||
double uncertaintyScore,
|
||||
string? uncertaintyFlags,
|
||||
double centralityScore,
|
||||
int degreeCentrality,
|
||||
double betweennessCentrality,
|
||||
double stalenessScore,
|
||||
int daysSinceAnalysis,
|
||||
double compositeScore,
|
||||
TriageBand triageBand,
|
||||
string? scoringTrace,
|
||||
DateTimeOffset? nextScheduledRescan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Unknown> RecordRescanAttemptAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string result,
|
||||
DateTimeOffset? nextRescan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyDictionary<TriageBand, long>> CountByTriageBandAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TriageSummary>> GetTriageSummaryAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
// Helper DTOs for raw SQL queries
|
||||
private sealed record UnknownDto;
|
||||
private sealed record KindCount(string Kind, long Count);
|
||||
private sealed record SeverityCount(string Severity, long Count);
|
||||
private sealed record CountResult(long Count);
|
||||
|
||||
private static Unknown MapToModel(UnknownDto dto)
|
||||
{
|
||||
// TODO: Implement mapping from scaffolded entity to domain model
|
||||
throw new NotImplementedException("Implement after scaffolding");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Unknowns.Persistence.EfCore</RootNamespace>
|
||||
<AssemblyName>StellaOps.Unknowns.Persistence.EfCore</AssemblyName>
|
||||
<Description>EF Core persistence layer for StellaOps Unknowns module with database-first scaffolding</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
# Placeholder for EF Core compiled models
|
||||
# Generated by: dotnet ef dbcontext optimize
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Infrastructure.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Unknowns.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the Unknowns module.
|
||||
/// </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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override string SchemaName => "unknowns";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new UnknownsDbContext.
|
||||
/// </summary>
|
||||
public UnknownsDbContext(DbContextOptions<UnknownsDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
// DbSet properties will be generated by scaffolding:
|
||||
// public virtual DbSet<Unknown> Unknowns { get; set; } = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Entity configurations will be generated by scaffolding
|
||||
// For now, configure any manual customizations here
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Placeholder for scaffolded EF Core entities
|
||||
# Run: .\devops\scripts\efcore\Scaffold-Module.ps1 -Module Unknowns
|
||||
@@ -0,0 +1 @@
|
||||
# Placeholder for entity-to-domain model mappings
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Core.Repositories;
|
||||
using StellaOps.Unknowns.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Unknowns.Persistence.EfCore.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IUnknownRepository"/>.
|
||||
/// </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;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new UnknownEfRepository.
|
||||
/// </summary>
|
||||
public UnknownEfRepository(UnknownsDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Unknown> CreateAsync(
|
||||
string tenantId,
|
||||
UnknownSubjectType subjectType,
|
||||
string subjectRef,
|
||||
UnknownKind kind,
|
||||
UnknownSeverity? severity,
|
||||
string? context,
|
||||
Guid? sourceScanId,
|
||||
Guid? sourceGraphId,
|
||||
string? sourceSbomDigest,
|
||||
string createdBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Implement after scaffolding generates entities
|
||||
throw new NotImplementedException("Scaffold entities first: ./devops/scripts/efcore/Scaffold-Module.ps1 -Module Unknowns");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Unknown?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Implement after scaffolding
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Unknown?> GetBySubjectHashAsync(string tenantId, string subjectHash, UnknownKind kind, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetOpenUnknownsAsync(
|
||||
string tenantId,
|
||||
int? limit = null,
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetByKindAsync(
|
||||
string tenantId,
|
||||
UnknownKind kind,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetBySeverityAsync(
|
||||
string tenantId,
|
||||
UnknownSeverity severity,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetByScanIdAsync(
|
||||
string tenantId,
|
||||
Guid scanId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Unknown> ResolveAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
ResolutionType resolutionType,
|
||||
string? resolutionRef,
|
||||
string? resolutionNotes,
|
||||
string resolvedBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SupersedeAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string supersededBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyDictionary<UnknownKind, long>> CountByKindAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyDictionary<UnknownSeverity, long>> CountBySeverityAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> CountOpenAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
// Triage methods
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetByTriageBandAsync(
|
||||
string tenantId,
|
||||
TriageBand band,
|
||||
int? limit = null,
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetHotQueueAsync(
|
||||
string tenantId,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Unknown>> GetDueForRescanAsync(
|
||||
string tenantId,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Unknown> UpdateScoresAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
double popularityScore,
|
||||
int deploymentCount,
|
||||
double exploitPotentialScore,
|
||||
double uncertaintyScore,
|
||||
string? uncertaintyFlags,
|
||||
double centralityScore,
|
||||
int degreeCentrality,
|
||||
double betweennessCentrality,
|
||||
double stalenessScore,
|
||||
int daysSinceAnalysis,
|
||||
double compositeScore,
|
||||
TriageBand triageBand,
|
||||
string? scoringTrace,
|
||||
DateTimeOffset? nextScheduledRescan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Unknown> RecordRescanAttemptAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string result,
|
||||
DateTimeOffset? nextRescan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyDictionary<TriageBand, long>> CountByTriageBandAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TriageSummary>> GetTriageSummaryAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException("Scaffold entities first");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.EfCore.Extensions;
|
||||
using StellaOps.Infrastructure.EfCore.Tenancy;
|
||||
using StellaOps.Unknowns.Core.Persistence;
|
||||
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";
|
||||
|
||||
/// <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)
|
||||
{
|
||||
// Register DbContext with tenant isolation
|
||||
services.AddStellaOpsDbContext<UnknownsDbContext>(
|
||||
connectionString,
|
||||
SchemaName);
|
||||
|
||||
// Register EF Core repository implementations
|
||||
services.AddScoped<IUnknownRepository, UnknownEfRepository>();
|
||||
|
||||
// Register persister (wraps repository)
|
||||
services.AddScoped<IUnknownPersister, PostgresUnknownPersister>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
services.AddStellaOpsDbContext<UnknownsDbContext>(
|
||||
connectionString,
|
||||
SchemaName);
|
||||
|
||||
// Register EF Core repository implementations
|
||||
services.AddScoped<IUnknownRepository, UnknownEfRepository>();
|
||||
|
||||
// Register persister
|
||||
services.AddScoped<IUnknownPersister, PostgresUnknownPersister>();
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddUnknownsSystemTenantContext(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ITenantContextAccessor>(SystemTenantContextAccessor.Instance);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Placeholder for in-memory repository implementations (testing)
|
||||
@@ -0,0 +1,523 @@
|
||||
-- Unknowns Schema Migration 001: Initial Schema (Consolidated)
|
||||
-- Consolidated from: 001_initial_schema.sql, 002_scoring_extension.sql
|
||||
-- Category: A (safe, can run at startup)
|
||||
--
|
||||
-- Purpose: Create the unknowns schema with bitemporal semantics for tracking
|
||||
-- ambiguity in vulnerability scans, enabling point-in-time compliance queries
|
||||
-- and intelligent triage with HOT/WARM/COLD band assignment.
|
||||
--
|
||||
-- Bitemporal Dimensions:
|
||||
-- - valid_from/valid_to: When the unknown was relevant in the real world
|
||||
-- - sys_from/sys_to: When the system recorded/knew about the unknown
|
||||
--
|
||||
-- Triage Bands:
|
||||
-- - HOT (>=0.70): Immediate rescan priority
|
||||
-- - WARM (0.40-0.69): 12-72h rescan window
|
||||
-- - COLD (<0.40): Weekly rescan
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 1: Create schemas
|
||||
-- ============================================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS unknowns;
|
||||
CREATE SCHEMA IF NOT EXISTS unknowns_app;
|
||||
|
||||
-- Tenant context helper function
|
||||
CREATE OR REPLACE FUNCTION unknowns_app.require_current_tenant()
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant TEXT;
|
||||
BEGIN
|
||||
v_tenant := current_setting('app.tenant_id', true);
|
||||
IF v_tenant IS NULL OR v_tenant = '' THEN
|
||||
RAISE EXCEPTION 'app.tenant_id session variable not set'
|
||||
USING HINT = 'Set via: SELECT set_config(''app.tenant_id'', ''<tenant>'', false)',
|
||||
ERRCODE = 'P0001';
|
||||
END IF;
|
||||
RETURN v_tenant;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION unknowns_app.require_current_tenant() FROM PUBLIC;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 2: Create enum types
|
||||
-- ============================================================================
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.subject_type AS ENUM (
|
||||
'package',
|
||||
'ecosystem',
|
||||
'version',
|
||||
'sbom_edge',
|
||||
'file',
|
||||
'runtime'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.unknown_kind AS ENUM (
|
||||
'missing_sbom',
|
||||
'ambiguous_package',
|
||||
'missing_feed',
|
||||
'unresolved_edge',
|
||||
'no_version_info',
|
||||
'unknown_ecosystem',
|
||||
'partial_match',
|
||||
'version_range_unbounded',
|
||||
'unsupported_format',
|
||||
'transitive_gap'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.unknown_severity AS ENUM (
|
||||
'critical',
|
||||
'high',
|
||||
'medium',
|
||||
'low',
|
||||
'info'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.resolution_type AS ENUM (
|
||||
'feed_updated',
|
||||
'sbom_provided',
|
||||
'manual_mapping',
|
||||
'superseded',
|
||||
'false_positive',
|
||||
'wont_fix'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.triage_band AS ENUM ('hot', 'warm', 'cold');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 3: Create main bitemporal unknowns table with scoring columns
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS unknowns.unknown (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
|
||||
-- Subject identification
|
||||
subject_hash CHAR(64) NOT NULL, -- SHA-256 hex of subject
|
||||
subject_type unknowns.subject_type NOT NULL,
|
||||
subject_ref TEXT NOT NULL, -- Human-readable reference (purl, name)
|
||||
|
||||
-- Classification
|
||||
kind unknowns.unknown_kind NOT NULL,
|
||||
severity unknowns.unknown_severity,
|
||||
|
||||
-- Context (flexible JSONB for additional details)
|
||||
context JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Source correlation
|
||||
source_scan_id UUID, -- Scan that discovered this unknown
|
||||
source_graph_id UUID, -- Graph revision context
|
||||
source_sbom_digest TEXT, -- SBOM digest if applicable
|
||||
|
||||
-- Bitemporal columns
|
||||
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
valid_to TIMESTAMPTZ, -- NULL = currently valid
|
||||
sys_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
sys_to TIMESTAMPTZ, -- NULL = current system state
|
||||
|
||||
-- Resolution tracking
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolution_type unknowns.resolution_type,
|
||||
resolution_ref TEXT, -- Reference to resolving entity
|
||||
resolution_notes TEXT,
|
||||
|
||||
-- Scoring: Popularity (P)
|
||||
-- Formula: min(1, log10(1 + deployments) / log10(1 + 100))
|
||||
popularity_score FLOAT DEFAULT 0.0,
|
||||
deployment_count INT DEFAULT 0,
|
||||
|
||||
-- Scoring: Exploit potential (E)
|
||||
-- Range: 0.0 (no known CVE) to 1.0 (critical CVE + KEV)
|
||||
exploit_potential_score FLOAT DEFAULT 0.0,
|
||||
|
||||
-- Scoring: Uncertainty density (U)
|
||||
-- Flags: no_provenance(0.30), version_range(0.25), conflicting_feeds(0.20),
|
||||
-- missing_vector(0.15), unreachable_source(0.10)
|
||||
uncertainty_score FLOAT DEFAULT 0.0,
|
||||
uncertainty_flags JSONB DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Scoring: Centrality (C)
|
||||
-- Based on normalized betweenness centrality
|
||||
centrality_score FLOAT DEFAULT 0.0,
|
||||
degree_centrality INT DEFAULT 0,
|
||||
betweenness_centrality FLOAT DEFAULT 0.0,
|
||||
|
||||
-- Scoring: Staleness (S)
|
||||
-- Formula: min(1, age_days / 14)
|
||||
staleness_score FLOAT DEFAULT 0.0,
|
||||
days_since_analysis INT DEFAULT 0,
|
||||
|
||||
-- Scoring: Composite
|
||||
-- Default weights: wP=0.25, wE=0.25, wU=0.25, wC=0.15, wS=0.10
|
||||
composite_score FLOAT DEFAULT 0.0,
|
||||
|
||||
-- Triage band: HOT (>=0.70), WARM (0.40-0.69), COLD (<0.40)
|
||||
triage_band unknowns.triage_band DEFAULT 'cold',
|
||||
|
||||
-- Normalization trace for audit/debugging
|
||||
scoring_trace JSONB,
|
||||
|
||||
-- Rescan scheduling
|
||||
rescan_attempts INT DEFAULT 0,
|
||||
last_rescan_result TEXT,
|
||||
next_scheduled_rescan TIMESTAMPTZ,
|
||||
last_analyzed_at TIMESTAMPTZ,
|
||||
|
||||
-- Evidence hashes for deterministic replay
|
||||
evidence_set_hash BYTEA,
|
||||
graph_slice_hash BYTEA,
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT NOT NULL DEFAULT 'system',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT ck_unknown_subject_hash_hex CHECK (subject_hash ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT ck_unknown_resolution_consistency CHECK (
|
||||
(resolved_at IS NULL AND resolution_type IS NULL) OR
|
||||
(resolved_at IS NOT NULL AND resolution_type IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT ck_unknown_temporal_order CHECK (
|
||||
(valid_to IS NULL OR valid_from <= valid_to) AND
|
||||
(sys_to IS NULL OR sys_from <= sys_to)
|
||||
),
|
||||
CONSTRAINT chk_popularity_range CHECK (popularity_score >= 0.0 AND popularity_score <= 1.0),
|
||||
CONSTRAINT chk_exploit_range CHECK (exploit_potential_score >= 0.0 AND exploit_potential_score <= 1.0),
|
||||
CONSTRAINT chk_uncertainty_range CHECK (uncertainty_score >= 0.0 AND uncertainty_score <= 1.0),
|
||||
CONSTRAINT chk_centrality_range CHECK (centrality_score >= 0.0 AND centrality_score <= 1.0),
|
||||
CONSTRAINT chk_staleness_range CHECK (staleness_score >= 0.0 AND staleness_score <= 1.0),
|
||||
CONSTRAINT chk_composite_range CHECK (composite_score >= 0.0 AND composite_score <= 1.0)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 4: Create indexes
|
||||
-- ============================================================================
|
||||
|
||||
-- Ensure only one open unknown per subject per tenant (current valid time, current system time)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_unknown_one_open_per_subject
|
||||
ON unknowns.unknown (tenant_id, subject_hash, kind)
|
||||
WHERE valid_to IS NULL AND sys_to IS NULL;
|
||||
|
||||
-- Tenant-scoped queries (most common)
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_tenant
|
||||
ON unknowns.unknown (tenant_id);
|
||||
|
||||
-- Temporal query indexes for valid time
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_tenant_valid
|
||||
ON unknowns.unknown (tenant_id, valid_from, valid_to);
|
||||
|
||||
-- Temporal query indexes for system time
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_tenant_sys
|
||||
ON unknowns.unknown (tenant_id, sys_from, sys_to);
|
||||
|
||||
-- Current open unknowns by kind and severity
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_tenant_kind_severity
|
||||
ON unknowns.unknown (tenant_id, kind, severity)
|
||||
WHERE valid_to IS NULL AND sys_to IS NULL;
|
||||
|
||||
-- Source correlation indexes
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_source_scan
|
||||
ON unknowns.unknown (source_scan_id)
|
||||
WHERE source_scan_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_source_graph
|
||||
ON unknowns.unknown (source_graph_id)
|
||||
WHERE source_graph_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_source_sbom
|
||||
ON unknowns.unknown (source_sbom_digest)
|
||||
WHERE source_sbom_digest IS NOT NULL;
|
||||
|
||||
-- Context GIN index for JSONB queries
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_context_gin
|
||||
ON unknowns.unknown USING GIN (context jsonb_path_ops);
|
||||
|
||||
-- Subject lookup
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_subject_ref
|
||||
ON unknowns.unknown (tenant_id, subject_ref);
|
||||
|
||||
-- Unresolved unknowns
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_unresolved
|
||||
ON unknowns.unknown (tenant_id, kind, created_at DESC)
|
||||
WHERE resolved_at IS NULL AND valid_to IS NULL AND sys_to IS NULL;
|
||||
|
||||
-- Band-based queries (most common for triage UI)
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_triage_band
|
||||
ON unknowns.unknown (tenant_id, triage_band, composite_score DESC)
|
||||
WHERE sys_to = 'infinity'::timestamptz;
|
||||
|
||||
-- Hot band priority queue
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_hot_queue
|
||||
ON unknowns.unknown (tenant_id, composite_score DESC)
|
||||
WHERE triage_band = 'hot' AND sys_to = 'infinity'::timestamptz;
|
||||
|
||||
-- Rescan scheduling
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_rescan_schedule
|
||||
ON unknowns.unknown (next_scheduled_rescan)
|
||||
WHERE next_scheduled_rescan IS NOT NULL AND sys_to = 'infinity'::timestamptz;
|
||||
|
||||
-- Kind + band for dashboard aggregations
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_kind_band
|
||||
ON unknowns.unknown (tenant_id, kind, triage_band)
|
||||
WHERE sys_to = 'infinity'::timestamptz;
|
||||
|
||||
-- GIN index for uncertainty flags queries
|
||||
CREATE INDEX IF NOT EXISTS ix_unknown_uncertainty_flags
|
||||
ON unknowns.unknown USING GIN (uncertainty_flags)
|
||||
WHERE sys_to = 'infinity'::timestamptz;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 5: Create views for common query patterns
|
||||
-- ============================================================================
|
||||
|
||||
-- Current unknowns (valid now, known now)
|
||||
CREATE OR REPLACE VIEW unknowns.current AS
|
||||
SELECT * FROM unknowns.unknown
|
||||
WHERE valid_to IS NULL AND sys_to IS NULL;
|
||||
|
||||
-- Resolved unknowns
|
||||
CREATE OR REPLACE VIEW unknowns.resolved AS
|
||||
SELECT * FROM unknowns.unknown
|
||||
WHERE resolved_at IS NOT NULL;
|
||||
|
||||
-- Triage summary by band
|
||||
CREATE OR REPLACE VIEW unknowns.triage_summary AS
|
||||
SELECT
|
||||
tenant_id,
|
||||
triage_band,
|
||||
kind::text AS unknown_kind,
|
||||
COUNT(*) AS count,
|
||||
AVG(composite_score) AS avg_score,
|
||||
MAX(composite_score) AS max_score,
|
||||
MIN(composite_score) AS min_score
|
||||
FROM unknowns.unknown
|
||||
WHERE sys_to = 'infinity'::timestamptz
|
||||
AND resolved_at IS NULL
|
||||
GROUP BY tenant_id, triage_band, kind;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 6: Create temporal query functions
|
||||
-- ============================================================================
|
||||
|
||||
-- Point-in-time query: What unknowns were valid at a given time?
|
||||
CREATE OR REPLACE FUNCTION unknowns.as_of(
|
||||
p_tenant_id TEXT,
|
||||
p_valid_at TIMESTAMPTZ,
|
||||
p_sys_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
RETURNS SETOF unknowns.unknown
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT * FROM unknowns.unknown
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND valid_from <= p_valid_at
|
||||
AND (valid_to IS NULL OR valid_to > p_valid_at)
|
||||
AND sys_from <= p_sys_at
|
||||
AND (sys_to IS NULL OR sys_to > p_sys_at);
|
||||
$$;
|
||||
|
||||
-- Count unknowns by kind for a tenant (current state)
|
||||
CREATE OR REPLACE FUNCTION unknowns.count_by_kind(p_tenant_id TEXT)
|
||||
RETURNS TABLE(kind unknowns.unknown_kind, count BIGINT)
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT kind, count(*)
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
GROUP BY kind;
|
||||
$$;
|
||||
|
||||
-- Count unknowns by severity for a tenant (current state)
|
||||
CREATE OR REPLACE FUNCTION unknowns.count_by_severity(p_tenant_id TEXT)
|
||||
RETURNS TABLE(severity unknowns.unknown_severity, count BIGINT)
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT severity, count(*)
|
||||
FROM unknowns.unknown
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND valid_to IS NULL
|
||||
AND sys_to IS NULL
|
||||
AND severity IS NOT NULL
|
||||
GROUP BY severity;
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 7: Create scoring helper functions
|
||||
-- ============================================================================
|
||||
|
||||
-- Calculate popularity score from deployment count
|
||||
CREATE OR REPLACE FUNCTION unknowns.calc_popularity_score(p_deployment_count INT)
|
||||
RETURNS FLOAT
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $$
|
||||
SELECT LEAST(1.0, LOG(1 + p_deployment_count) / LOG(1 + 100))::float;
|
||||
$$;
|
||||
|
||||
-- Calculate staleness score from days since analysis
|
||||
CREATE OR REPLACE FUNCTION unknowns.calc_staleness_score(p_days INT)
|
||||
RETURNS FLOAT
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $$
|
||||
SELECT LEAST(1.0, p_days / 14.0)::float;
|
||||
$$;
|
||||
|
||||
-- Assign triage band from composite score
|
||||
CREATE OR REPLACE FUNCTION unknowns.assign_band(p_score FLOAT)
|
||||
RETURNS unknowns.triage_band
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $$
|
||||
SELECT CASE
|
||||
WHEN p_score >= 0.70 THEN 'hot'::unknowns.triage_band
|
||||
WHEN p_score >= 0.40 THEN 'warm'::unknowns.triage_band
|
||||
ELSE 'cold'::unknowns.triage_band
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Calculate composite score with default weights
|
||||
-- wP=0.25, wE=0.25, wU=0.25, wC=0.15, wS=0.10
|
||||
CREATE OR REPLACE FUNCTION unknowns.calc_composite_score(
|
||||
p_popularity FLOAT,
|
||||
p_exploit FLOAT,
|
||||
p_uncertainty FLOAT,
|
||||
p_centrality FLOAT,
|
||||
p_staleness FLOAT,
|
||||
p_weights JSONB DEFAULT '{"wP":0.25,"wE":0.25,"wU":0.25,"wC":0.15,"wS":0.10}'::jsonb
|
||||
)
|
||||
RETURNS FLOAT
|
||||
LANGUAGE plpgsql IMMUTABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_wp FLOAT := COALESCE((p_weights->>'wP')::float, 0.25);
|
||||
v_we FLOAT := COALESCE((p_weights->>'wE')::float, 0.25);
|
||||
v_wu FLOAT := COALESCE((p_weights->>'wU')::float, 0.25);
|
||||
v_wc FLOAT := COALESCE((p_weights->>'wC')::float, 0.15);
|
||||
v_ws FLOAT := COALESCE((p_weights->>'wS')::float, 0.10);
|
||||
v_score FLOAT;
|
||||
BEGIN
|
||||
v_score := v_wp * COALESCE(p_popularity, 0) +
|
||||
v_we * COALESCE(p_exploit, 0) +
|
||||
v_wu * COALESCE(p_uncertainty, 0) +
|
||||
v_wc * COALESCE(p_centrality, 0) +
|
||||
v_ws * COALESCE(p_staleness, 0);
|
||||
RETURN LEAST(1.0, GREATEST(0.0, v_score));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 8: Enable RLS
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE unknowns.unknown ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE unknowns.unknown FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS unknown_tenant_isolation ON unknowns.unknown;
|
||||
CREATE POLICY unknown_tenant_isolation ON unknowns.unknown
|
||||
FOR ALL
|
||||
USING (tenant_id = unknowns_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = unknowns_app.require_current_tenant());
|
||||
|
||||
-- Admin bypass role
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'unknowns_admin') THEN
|
||||
CREATE ROLE unknowns_admin WITH NOLOGIN BYPASSRLS;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 9: Create update trigger for updated_at
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION unknowns.update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_unknown_updated_at ON unknowns.unknown;
|
||||
CREATE TRIGGER trg_unknown_updated_at
|
||||
BEFORE UPDATE ON unknowns.unknown
|
||||
FOR EACH ROW EXECUTE FUNCTION unknowns.update_updated_at();
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 10: Add column comments
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.popularity_score IS
|
||||
'Popularity impact (P). Formula: min(1, log10(1+deployments)/log10(101)). Range [0,1].';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.exploit_potential_score IS
|
||||
'Exploit consequence potential (E). Based on CVE severity + KEV status. Range [0,1].';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.uncertainty_score IS
|
||||
'Uncertainty density (U). Aggregated from flags. Range [0,1].';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.centrality_score IS
|
||||
'Graph centrality (C). Normalized betweenness centrality. Range [0,1].';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.staleness_score IS
|
||||
'Evidence staleness (S). Formula: min(1, age_days/14). Range [0,1].';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.composite_score IS
|
||||
'Weighted composite: clamp01(0.25*P + 0.25*E + 0.25*U + 0.15*C + 0.10*S).';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.triage_band IS
|
||||
'HOT (>=0.70): immediate rescan. WARM (0.40-0.69): 12-72h. COLD (<0.40): weekly.';
|
||||
|
||||
COMMENT ON COLUMN unknowns.unknown.scoring_trace IS
|
||||
'JSONB trace of scoring computation for audit/debugging.';
|
||||
|
||||
COMMENT ON FUNCTION unknowns.calc_composite_score IS
|
||||
'Calculate weighted composite score. Default weights: wP=0.25, wE=0.25, wU=0.25, wC=0.15, wS=0.10.';
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================================================
|
||||
-- VERIFICATION (run manually)
|
||||
-- ============================================================================
|
||||
-- SELECT
|
||||
-- id,
|
||||
-- kind::text,
|
||||
-- triage_band::text,
|
||||
-- composite_score,
|
||||
-- popularity_score,
|
||||
-- exploit_potential_score,
|
||||
-- uncertainty_score,
|
||||
-- centrality_score,
|
||||
-- staleness_score
|
||||
-- FROM unknowns.unknown
|
||||
-- WHERE sys_to = 'infinity'::timestamptz
|
||||
-- ORDER BY composite_score DESC
|
||||
-- LIMIT 10;
|
||||
@@ -3,7 +3,7 @@ using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Core.Persistence;
|
||||
using StellaOps.Unknowns.Core.Repositories;
|
||||
|
||||
namespace StellaOps.Unknowns.Storage.Postgres.Persistence;
|
||||
namespace StellaOps.Unknowns.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the unknown persister.
|
||||
@@ -7,7 +7,7 @@ using NpgsqlTypes;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Core.Repositories;
|
||||
|
||||
namespace StellaOps.Unknowns.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Unknowns.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the bitemporal unknowns repository.
|
||||
@@ -803,9 +803,15 @@ public sealed class PostgresUnknownRepository : IUnknownRepository
|
||||
|
||||
private static Unknown MapUnknown(NpgsqlDataReader reader)
|
||||
{
|
||||
// Column indices match SelectColumns order:
|
||||
// 0-10: id, tenant_id, subject_hash, subject_type, subject_ref, kind, severity, context, source_scan_id, source_graph_id, source_sbom_digest
|
||||
// 11-21: valid_from, valid_to, sys_from, sys_to, resolved_at, resolution_type, resolution_ref, resolution_notes, created_at, created_by, updated_at
|
||||
// 22-26: popularity_score, deployment_count, exploit_potential_score, uncertainty_score, uncertainty_flags
|
||||
// 27-34: centrality_score, degree_centrality, betweenness_centrality, staleness_score, days_since_analysis, composite_score, triage_band, scoring_trace
|
||||
// 35-40: rescan_attempts, last_rescan_result, next_scheduled_rescan, last_analyzed_at, evidence_set_hash, graph_slice_hash
|
||||
var contextJson = reader.IsDBNull(7) ? null : reader.GetFieldValue<string>(7);
|
||||
var uncertaintyFlagsJson = reader.IsDBNull(25) ? null : reader.GetFieldValue<string>(25);
|
||||
var scoringTraceJson = reader.IsDBNull(33) ? null : reader.GetFieldValue<string>(33);
|
||||
var uncertaintyFlagsJson = reader.IsDBNull(26) ? null : reader.GetFieldValue<string>(26);
|
||||
var scoringTraceJson = reader.IsDBNull(34) ? null : reader.GetFieldValue<string>(34);
|
||||
|
||||
return new Unknown
|
||||
{
|
||||
@@ -831,7 +837,7 @@ public sealed class PostgresUnknownRepository : IUnknownRepository
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(19),
|
||||
CreatedBy = reader.GetString(20),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(21),
|
||||
// Scoring fields
|
||||
// Scoring fields (indices 22-40)
|
||||
PopularityScore = reader.IsDBNull(22) ? 0.0 : reader.GetDouble(22),
|
||||
DeploymentCount = reader.IsDBNull(23) ? 0 : reader.GetInt32(23),
|
||||
ExploitPotentialScore = reader.IsDBNull(24) ? 0.0 : reader.GetDouble(24),
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Unknowns.Persistence</RootNamespace>
|
||||
<AssemblyName>StellaOps.Unknowns.Persistence</AssemblyName>
|
||||
<Description>Consolidated persistence layer for StellaOps Unknowns module (EF Core + Raw SQL + InMemory)</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Embed SQL migrations as resources -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Unknowns.Storage.Postgres</RootNamespace>
|
||||
<Description>PostgreSQL storage implementation for the StellaOps Unknowns module (bitemporal ambiguity tracking)</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -33,7 +33,7 @@ public class UnknownRankerTests
|
||||
var score = _ranker.Rank(blast, scarcity: 0.8, pressure, containment);
|
||||
|
||||
// Assert - should be very high (close to 1.0)
|
||||
score.Should().BeGreaterOrEqualTo(0.8);
|
||||
score.Should().BeGreaterThanOrEqualTo(0.8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -66,7 +66,7 @@ public class UnknownRankerTests
|
||||
|
||||
// Assert - containment should reduce score
|
||||
scoreWellContained.Should().BeLessThan(scoreNoContainment);
|
||||
(scoreNoContainment - scoreWellContained).Should().BeGreaterOrEqualTo(0.15); // At least 0.15 reduction
|
||||
(scoreNoContainment - scoreWellContained).Should().BeGreaterThanOrEqualTo(0.15); // At least 0.15 reduction
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -109,8 +109,8 @@ public class UnknownRankerTests
|
||||
var deduction = containment.Deduction();
|
||||
|
||||
// Assert - should be significant
|
||||
deduction.Should().BeGreaterOrEqualTo(0.25);
|
||||
deduction.Should().BeLessOrEqualTo(0.30);
|
||||
deduction.Should().BeGreaterThanOrEqualTo(0.25);
|
||||
deduction.Should().BeLessThanOrEqualTo(0.30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -297,7 +297,7 @@ public class UnknownRankerTests
|
||||
var score = _ranker.Rank(blast, scarcity: 0, pressure, containment);
|
||||
|
||||
// Assert - should be clamped to 0, not negative
|
||||
score.Should().BeGreaterOrEqualTo(0);
|
||||
score.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -12,23 +12,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Time.Testing" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Unknowns.Core/StellaOps.Unknowns.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -2,13 +2,13 @@ using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Storage.Postgres.Repositories;
|
||||
using StellaOps.Unknowns.Persistence.Postgres.Repositories;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Unknowns.Storage.Postgres.Tests;
|
||||
namespace StellaOps.Unknowns.Persistence.Tests;
|
||||
|
||||
public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
@@ -96,6 +96,11 @@ public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.triage_band AS ENUM ('hot', 'warm', 'cold');
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS unknowns.unknown (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
@@ -118,7 +123,27 @@ public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
|
||||
resolution_notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT NOT NULL DEFAULT 'system',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
-- Scoring columns (from 002_scoring_extension.sql)
|
||||
popularity_score FLOAT DEFAULT 0.0,
|
||||
deployment_count INT DEFAULT 0,
|
||||
exploit_potential_score FLOAT DEFAULT 0.0,
|
||||
uncertainty_score FLOAT DEFAULT 0.0,
|
||||
uncertainty_flags JSONB DEFAULT '{}'::jsonb,
|
||||
centrality_score FLOAT DEFAULT 0.0,
|
||||
degree_centrality INT DEFAULT 0,
|
||||
betweenness_centrality FLOAT DEFAULT 0.0,
|
||||
staleness_score FLOAT DEFAULT 0.0,
|
||||
days_since_analysis INT DEFAULT 0,
|
||||
composite_score FLOAT DEFAULT 0.0,
|
||||
triage_band unknowns.triage_band DEFAULT 'cold',
|
||||
scoring_trace JSONB,
|
||||
rescan_attempts INT DEFAULT 0,
|
||||
last_rescan_result TEXT,
|
||||
next_scheduled_rescan TIMESTAMPTZ,
|
||||
last_analyzed_at TIMESTAMPTZ,
|
||||
evidence_set_hash BYTEA,
|
||||
graph_slice_hash BYTEA
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_unknown_one_open_per_subject
|
||||
@@ -127,7 +152,6 @@ public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(schema, connection);
|
||||
using StellaOps.TestKit;
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Unknowns.Persistence.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Unknowns.Persistence\StellaOps.Unknowns.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Unknowns.Storage.Postgres.Tests</RootNamespace>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.4.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Unknowns.Storage.Postgres\StellaOps.Unknowns.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user