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:
@@ -1,5 +1,6 @@
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
@@ -14,7 +15,8 @@ internal static class AdvisorySourceEndpointExtensions
|
||||
public static void MapAdvisorySourceEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/advisory-sources")
|
||||
.WithTags("Advisory Sources");
|
||||
.WithTags("Advisory Sources")
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet(string.Empty, async (
|
||||
HttpContext httpContext,
|
||||
@@ -23,11 +25,6 @@ internal static class AdvisorySourceEndpointExtensions
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out _))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
|
||||
}
|
||||
|
||||
var records = await readRepository.ListAsync(includeDisabled, cancellationToken).ConfigureAwait(false);
|
||||
var items = records.Select(MapListItem).ToList();
|
||||
|
||||
@@ -40,6 +37,7 @@ internal static class AdvisorySourceEndpointExtensions
|
||||
})
|
||||
.WithName("ListAdvisorySources")
|
||||
.WithSummary("List advisory sources with freshness state")
|
||||
.WithDescription("Returns all registered advisory sources with their current freshness status, sync timestamps, and signature validity. Supports filtering of disabled sources via query parameter.")
|
||||
.Produces<AdvisorySourceListResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(AdvisoryReadPolicy);
|
||||
|
||||
@@ -49,11 +47,6 @@ internal static class AdvisorySourceEndpointExtensions
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out _))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
|
||||
}
|
||||
|
||||
var records = await readRepository.ListAsync(includeDisabled: true, cancellationToken).ConfigureAwait(false);
|
||||
var response = new AdvisorySourceSummaryResponse
|
||||
{
|
||||
@@ -71,6 +64,7 @@ internal static class AdvisorySourceEndpointExtensions
|
||||
})
|
||||
.WithName("GetAdvisorySourceSummary")
|
||||
.WithSummary("Get advisory source summary cards")
|
||||
.WithDescription("Returns aggregated health counters across all advisory sources: healthy, warning, stale, unavailable, and disabled counts. Used by the UI v2 dashboard header cards.")
|
||||
.Produces<AdvisorySourceSummaryResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(AdvisoryReadPolicy);
|
||||
|
||||
@@ -82,11 +76,6 @@ internal static class AdvisorySourceEndpointExtensions
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out _))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "source_id_required" });
|
||||
@@ -126,32 +115,12 @@ internal static class AdvisorySourceEndpointExtensions
|
||||
})
|
||||
.WithName("GetAdvisorySourceFreshness")
|
||||
.WithSummary("Get freshness details for one advisory source")
|
||||
.WithDescription("Returns detailed freshness metrics for a single advisory source identified by GUID or source key. Includes last sync time, last success time, error count, and SLA tracking.")
|
||||
.Produces<AdvisorySourceFreshnessResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(AdvisoryReadPolicy);
|
||||
}
|
||||
|
||||
private static bool TryGetTenant(HttpContext httpContext, out string tenant)
|
||||
{
|
||||
tenant = string.Empty;
|
||||
|
||||
var claimTenant = httpContext.User?.FindFirst("tenant_id")?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
tenant = claimTenant.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
var headerTenant = httpContext.Request.Headers[StellaOps.Concelier.WebService.Program.TenantHeaderName].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(headerTenant))
|
||||
{
|
||||
tenant = headerTenant.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static AdvisorySourceListItem MapListItem(AdvisorySourceFreshnessRecord record)
|
||||
{
|
||||
return new AdvisorySourceListItem
|
||||
|
||||
@@ -3,6 +3,7 @@ using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Concelier.Core.AirGap;
|
||||
using StellaOps.Concelier.Core.AirGap.Models;
|
||||
using StellaOps.Concelier.WebService.Diagnostics;
|
||||
@@ -21,7 +22,8 @@ internal static class AirGapEndpointExtensions
|
||||
public static void MapConcelierAirGapEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/concelier/airgap")
|
||||
.WithTags("AirGap");
|
||||
.WithTags("AirGap")
|
||||
.RequireTenant();
|
||||
|
||||
// GET /api/v1/concelier/airgap/catalog - Aggregated bundle catalog
|
||||
group.MapGet("/catalog", async (
|
||||
@@ -42,7 +44,11 @@ internal static class AirGapEndpointExtensions
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(catalog);
|
||||
});
|
||||
})
|
||||
.WithName("GetAirGapCatalog")
|
||||
.WithSummary("Get aggregated air-gap bundle catalog")
|
||||
.WithDescription("Returns the paginated catalog of all available air-gap bundles from registered sources. Requires the air-gap feature to be enabled in configuration.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// GET /api/v1/concelier/airgap/sources - List registered sources
|
||||
group.MapGet("/sources", (
|
||||
@@ -58,7 +64,11 @@ internal static class AirGapEndpointExtensions
|
||||
|
||||
var sources = sourceRegistry.GetSources();
|
||||
return HttpResults.Ok(new { sources, count = sources.Count });
|
||||
});
|
||||
})
|
||||
.WithName("ListAirGapSources")
|
||||
.WithSummary("List registered air-gap bundle sources")
|
||||
.WithDescription("Returns all bundle sources currently registered in the air-gap source registry.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// POST /api/v1/concelier/airgap/sources - Register new source
|
||||
group.MapPost("/sources", async (
|
||||
@@ -83,7 +93,11 @@ internal static class AirGapEndpointExtensions
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Created($"/api/v1/concelier/airgap/sources/{source.Id}", source);
|
||||
});
|
||||
})
|
||||
.WithName("RegisterAirGapSource")
|
||||
.WithSummary("Register a new air-gap bundle source")
|
||||
.WithDescription("Registers a new bundle source in the air-gap source registry. The source ID must be unique and is used to identify the source in subsequent operations.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// GET /api/v1/concelier/airgap/sources/{sourceId} - Get specific source
|
||||
group.MapGet("/sources/{sourceId}", (
|
||||
@@ -105,7 +119,11 @@ internal static class AirGapEndpointExtensions
|
||||
}
|
||||
|
||||
return HttpResults.Ok(source);
|
||||
});
|
||||
})
|
||||
.WithName("GetAirGapSource")
|
||||
.WithSummary("Get a specific air-gap bundle source")
|
||||
.WithDescription("Returns the registration details for a specific bundle source identified by its source ID.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// DELETE /api/v1/concelier/airgap/sources/{sourceId} - Unregister source
|
||||
group.MapDelete("/sources/{sourceId}", async (
|
||||
@@ -127,7 +145,11 @@ internal static class AirGapEndpointExtensions
|
||||
return removed
|
||||
? HttpResults.NoContent()
|
||||
: ConcelierProblemResultFactory.BundleSourceNotFound(context, sourceId);
|
||||
});
|
||||
})
|
||||
.WithName("UnregisterAirGapSource")
|
||||
.WithSummary("Unregister an air-gap bundle source")
|
||||
.WithDescription("Removes a bundle source from the air-gap registry. This does not delete any previously downloaded bundles.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// POST /api/v1/concelier/airgap/sources/{sourceId}/validate - Validate source
|
||||
group.MapPost("/sources/{sourceId}/validate", async (
|
||||
@@ -147,7 +169,11 @@ internal static class AirGapEndpointExtensions
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(result);
|
||||
});
|
||||
})
|
||||
.WithName("ValidateAirGapSource")
|
||||
.WithSummary("Validate an air-gap bundle source")
|
||||
.WithDescription("Runs connectivity and integrity checks against a registered bundle source to verify it is reachable and correctly configured.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// GET /api/v1/concelier/airgap/status - Sealed-mode status
|
||||
group.MapGet("/status", (
|
||||
@@ -163,7 +189,11 @@ internal static class AirGapEndpointExtensions
|
||||
|
||||
var status = sealedModeEnforcer.GetStatus();
|
||||
return HttpResults.Ok(status);
|
||||
});
|
||||
})
|
||||
.WithName("GetAirGapStatus")
|
||||
.WithSummary("Get air-gap sealed-mode status")
|
||||
.WithDescription("Returns the current sealed-mode enforcement status, indicating whether the node is operating in full air-gap mode with all external network access blocked.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// POST /api/v1/concelier/airgap/bundles/{bundleId}/import - Import a bundle with timeline event
|
||||
// Per CONCELIER-WEB-AIRGAP-58-001
|
||||
@@ -251,7 +281,11 @@ internal static class AirGapEndpointExtensions
|
||||
Stats = importStats,
|
||||
OccurredAt = timelineEvent.OccurredAt
|
||||
});
|
||||
});
|
||||
})
|
||||
.WithName("ImportAirGapBundle")
|
||||
.WithSummary("Import an air-gap bundle with timeline event")
|
||||
.WithDescription("Imports a specific bundle from the catalog into the tenant's advisory database and emits a timeline event recording the import actor, scope, and statistics.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Concelier.Interest;
|
||||
using StellaOps.Concelier.Merge.Backport;
|
||||
@@ -26,7 +27,8 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
public static void MapCanonicalAdvisoryEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/canonical")
|
||||
.WithTags("Canonical Advisories");
|
||||
.WithTags("Canonical Advisories")
|
||||
.RequireTenant();
|
||||
|
||||
// GET /api/v1/canonical/{id} - Get canonical advisory by ID
|
||||
group.MapGet("/{id:guid}", async (
|
||||
@@ -54,8 +56,10 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
})
|
||||
.WithName("GetCanonicalById")
|
||||
.WithSummary("Get canonical advisory by ID")
|
||||
.WithDescription("Returns the merged canonical advisory record by its unique GUID, including all source edges, interest score, version range, and weaknesses.")
|
||||
.Produces<CanonicalAdvisoryResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(CanonicalReadPolicy);
|
||||
|
||||
// GET /api/v1/canonical?cve={cve}&artifact={artifact} - Query canonical advisories
|
||||
group.MapGet("/", async (
|
||||
@@ -121,7 +125,9 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
})
|
||||
.WithName("QueryCanonical")
|
||||
.WithSummary("Query canonical advisories by CVE, artifact, or merge hash")
|
||||
.Produces<CanonicalAdvisoryListResponse>(StatusCodes.Status200OK);
|
||||
.WithDescription("Searches canonical advisories by CVE identifier, artifact package URL, or merge hash. Query by merge hash takes precedence. Falls back to paginated generic query when no filter is specified.")
|
||||
.Produces<CanonicalAdvisoryListResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(CanonicalReadPolicy);
|
||||
|
||||
// POST /api/v1/canonical/ingest/{source} - Ingest raw advisory
|
||||
group.MapPost("/ingest/{source}", async (
|
||||
@@ -181,9 +187,11 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
})
|
||||
.WithName("IngestAdvisory")
|
||||
.WithSummary("Ingest raw advisory from source into canonical pipeline")
|
||||
.WithDescription("Ingests a single raw advisory from the named source into the canonical merge pipeline. Returns the merge decision (Created, Merged, Duplicate, or Conflict) and the resulting canonical ID.")
|
||||
.Produces<IngestResultResponse>(StatusCodes.Status200OK)
|
||||
.Produces<IngestResultResponse>(StatusCodes.Status409Conflict)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(CanonicalIngestPolicy);
|
||||
|
||||
// POST /api/v1/canonical/ingest/{source}/batch - Batch ingest advisories
|
||||
group.MapPost("/ingest/{source}/batch", async (
|
||||
@@ -243,8 +251,10 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
})
|
||||
.WithName("IngestAdvisoryBatch")
|
||||
.WithSummary("Batch ingest multiple advisories from source")
|
||||
.WithDescription("Ingests a batch of raw advisories from the named source into the canonical merge pipeline. Returns per-item merge decisions and a summary with total created, merged, duplicate, and conflict counts.")
|
||||
.Produces<BatchIngestResultResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(CanonicalIngestPolicy);
|
||||
|
||||
// PATCH /api/v1/canonical/{id}/status - Update canonical status
|
||||
group.MapPatch("/{id:guid}/status", async (
|
||||
@@ -265,8 +275,10 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
})
|
||||
.WithName("UpdateCanonicalStatus")
|
||||
.WithSummary("Update canonical advisory status")
|
||||
.WithDescription("Updates the lifecycle status (Active, Disputed, Suppressed, Withdrawn) of a canonical advisory. Used by triage workflows to manage advisory state.")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(CanonicalIngestPolicy);
|
||||
|
||||
// GET /api/v1/canonical/{id}/provenance - Get provenance scopes for canonical
|
||||
group.MapGet("/{id:guid}/provenance", async (
|
||||
@@ -304,9 +316,10 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
})
|
||||
.WithName("GetCanonicalProvenance")
|
||||
.WithSummary("Get provenance scopes for canonical advisory")
|
||||
.WithDescription("Returns distro-specific backport and patch provenance information for a canonical advisory")
|
||||
.WithDescription("Returns distro-specific backport and patch provenance scopes for a canonical advisory, including patch origin, evidence references, and confidence scores per distribution release.")
|
||||
.Produces<ProvenanceScopeListResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(CanonicalReadPolicy);
|
||||
}
|
||||
|
||||
private static ProvenanceScopeResponse MapToProvenanceResponse(ProvenanceScope scope) => new()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Concelier.Federation.Export;
|
||||
using StellaOps.Concelier.Federation.Import;
|
||||
using StellaOps.Concelier.Federation.Models;
|
||||
@@ -20,7 +21,8 @@ internal static class FederationEndpointExtensions
|
||||
public static void MapConcelierFederationEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/federation")
|
||||
.WithTags("Federation");
|
||||
.WithTags("Federation")
|
||||
.RequireTenant();
|
||||
|
||||
// GET /api/v1/federation/export - Export delta bundle
|
||||
group.MapGet("/export", async (
|
||||
@@ -84,9 +86,11 @@ internal static class FederationEndpointExtensions
|
||||
})
|
||||
.WithName("ExportFederationBundle")
|
||||
.WithSummary("Export delta bundle for federation sync")
|
||||
.WithDescription("Generates and streams a zstd-compressed delta bundle of canonical advisories since the specified cursor. Optionally signs the bundle. Used for inter-node federation replication.")
|
||||
.Produces(200, contentType: "application/zstd")
|
||||
.ProducesProblem(400)
|
||||
.ProducesProblem(503);
|
||||
.ProducesProblem(503)
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// GET /api/v1/federation/export/preview - Preview export statistics
|
||||
group.MapGet("/export/preview", async (
|
||||
@@ -116,8 +120,10 @@ internal static class FederationEndpointExtensions
|
||||
})
|
||||
.WithName("PreviewFederationExport")
|
||||
.WithSummary("Preview export statistics without creating bundle")
|
||||
.WithDescription("Returns estimated counts of canonicals, edges, and deletions that would be included in a delta bundle since the specified cursor, without generating the actual bundle.")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(503);
|
||||
.ProducesProblem(503)
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// GET /api/v1/federation/status - Federation status
|
||||
group.MapGet("/status", (
|
||||
@@ -136,7 +142,9 @@ internal static class FederationEndpointExtensions
|
||||
})
|
||||
.WithName("GetFederationStatus")
|
||||
.WithSummary("Get federation configuration status")
|
||||
.Produces<object>(200);
|
||||
.WithDescription("Returns the current federation configuration including enabled state, site ID, and default operational parameters for compression and item limits.")
|
||||
.Produces<object>(200)
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// POST /api/v1/federation/import - Import a bundle
|
||||
// Per SPRINT_8200_0014_0003_CONCEL_bundle_import_merge Task 25-26.
|
||||
@@ -228,12 +236,14 @@ internal static class FederationEndpointExtensions
|
||||
})
|
||||
.WithName("ImportFederationBundle")
|
||||
.WithSummary("Import a federation bundle")
|
||||
.WithDescription("Imports a zstd-compressed federation bundle into the canonical advisory database. Supports dry-run mode, signature skipping, and configurable conflict resolution strategy (PreferRemote, PreferLocal, Fail).")
|
||||
.Accepts<Stream>("application/zstd")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(400)
|
||||
.ProducesProblem(422)
|
||||
.ProducesProblem(503)
|
||||
.DisableAntiforgery();
|
||||
.DisableAntiforgery()
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// POST /api/v1/federation/import/validate - Validate bundle without importing
|
||||
group.MapPost("/import/validate", async (
|
||||
@@ -264,10 +274,12 @@ internal static class FederationEndpointExtensions
|
||||
})
|
||||
.WithName("ValidateFederationBundle")
|
||||
.WithSummary("Validate a bundle without importing")
|
||||
.WithDescription("Performs structural and cryptographic validation of a zstd-compressed federation bundle without persisting any data. Returns hash validity, signature validity, cursor validity, and any validation errors or warnings.")
|
||||
.Accepts<Stream>("application/zstd")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(503)
|
||||
.DisableAntiforgery();
|
||||
.DisableAntiforgery()
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// POST /api/v1/federation/import/preview - Preview import
|
||||
group.MapPost("/import/preview", async (
|
||||
@@ -312,10 +324,12 @@ internal static class FederationEndpointExtensions
|
||||
})
|
||||
.WithName("PreviewFederationImport")
|
||||
.WithSummary("Preview what import would do")
|
||||
.WithDescription("Inspects a zstd-compressed federation bundle and returns its manifest details including item counts, site ID, export cursor, and duplicate detection status, without applying any changes to the advisory database.")
|
||||
.Accepts<Stream>("application/zstd")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(503)
|
||||
.DisableAntiforgery();
|
||||
.DisableAntiforgery()
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// GET /api/v1/federation/sites - List all federation sites
|
||||
// Per SPRINT_8200_0014_0003_CONCEL_bundle_import_merge Task 30.
|
||||
@@ -352,8 +366,10 @@ internal static class FederationEndpointExtensions
|
||||
})
|
||||
.WithName("ListFederationSites")
|
||||
.WithSummary("List all federation sites")
|
||||
.WithDescription("Returns all registered federation peer sites with their sync state, last cursor, import counts, and access policy. Supports filtering to enabled sites only via the enabled_only query parameter.")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(503);
|
||||
.ProducesProblem(503)
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// GET /api/v1/federation/sites/{siteId} - Get site details
|
||||
group.MapGet("/sites/{siteId}", async (
|
||||
@@ -404,9 +420,11 @@ internal static class FederationEndpointExtensions
|
||||
})
|
||||
.WithName("GetFederationSite")
|
||||
.WithSummary("Get federation site details")
|
||||
.WithDescription("Returns full configuration and recent sync history for a specific federation peer site identified by site ID. Includes the last 10 import history entries with cursor, hash, and item counts per sync.")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(404)
|
||||
.ProducesProblem(503);
|
||||
.ProducesProblem(503)
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// PUT /api/v1/federation/sites/{siteId}/policy - Update site policy
|
||||
// Per SPRINT_8200_0014_0003_CONCEL_bundle_import_merge Task 31.
|
||||
@@ -450,9 +468,11 @@ internal static class FederationEndpointExtensions
|
||||
})
|
||||
.WithName("UpdateFederationSitePolicy")
|
||||
.WithSummary("Update federation site policy")
|
||||
.WithDescription("Creates or updates the access policy for a federation peer site, controlling its enabled state, allowed advisory sources, and maximum bundle size. Existing values are preserved for any fields not provided in the request body.")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(400)
|
||||
.ProducesProblem(503);
|
||||
.ProducesProblem(503)
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
@@ -15,55 +16,162 @@ internal static class FeedMirrorManagementEndpoints
|
||||
{
|
||||
// Mirror management
|
||||
var mirrors = app.MapGroup("/api/v1/concelier/mirrors")
|
||||
.WithTags("FeedMirrors");
|
||||
.WithTags("FeedMirrors")
|
||||
.RequireAuthorization("Concelier.Advisories.Read")
|
||||
.RequireTenant();
|
||||
|
||||
mirrors.MapGet(string.Empty, ListMirrors);
|
||||
mirrors.MapGet("/{mirrorId}", GetMirror);
|
||||
mirrors.MapPatch("/{mirrorId}", UpdateMirrorConfig);
|
||||
mirrors.MapPost("/{mirrorId}/sync", TriggerSync);
|
||||
mirrors.MapGet("/{mirrorId}/snapshots", ListMirrorSnapshots);
|
||||
mirrors.MapGet("/{mirrorId}/retention", GetRetentionConfig);
|
||||
mirrors.MapPut("/{mirrorId}/retention", UpdateRetentionConfig);
|
||||
mirrors.MapGet(string.Empty, ListMirrors)
|
||||
.WithName("ListFeedMirrors")
|
||||
.WithSummary("List feed mirrors")
|
||||
.WithDescription("Returns all registered feed mirrors with their sync status, last sync timestamp, upstream URL, and snapshot counts. Supports filtering by feed type, sync status, enabled state, and name search.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
mirrors.MapGet("/{mirrorId}", GetMirror)
|
||||
.WithName("GetFeedMirror")
|
||||
.WithSummary("Get feed mirror details")
|
||||
.WithDescription("Returns the full configuration and current state for a specific feed mirror identified by its mirror ID, including sync schedule, storage usage, and latest snapshot reference.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
mirrors.MapPatch("/{mirrorId}", UpdateMirrorConfig)
|
||||
.WithName("UpdateFeedMirrorConfig")
|
||||
.WithSummary("Update feed mirror configuration")
|
||||
.WithDescription("Updates the configuration of a specific feed mirror, including enabled state, sync interval, and upstream URL. Only provided fields are modified; all others retain their current values.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
mirrors.MapPost("/{mirrorId}/sync", TriggerSync)
|
||||
.WithName("TriggerFeedMirrorSync")
|
||||
.WithSummary("Trigger an immediate sync for a feed mirror")
|
||||
.WithDescription("Initiates an out-of-schedule synchronization for the specified feed mirror. Returns the resulting snapshot ID and record count upon completion.")
|
||||
.RequireAuthorization("Concelier.Jobs.Trigger");
|
||||
mirrors.MapGet("/{mirrorId}/snapshots", ListMirrorSnapshots)
|
||||
.WithName("ListMirrorSnapshots")
|
||||
.WithSummary("List snapshots for a feed mirror")
|
||||
.WithDescription("Returns all stored snapshots for a specific feed mirror, including version, creation time, size, checksums, record count, and pinned state.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
mirrors.MapGet("/{mirrorId}/retention", GetRetentionConfig)
|
||||
.WithName("GetMirrorRetentionConfig")
|
||||
.WithSummary("Get snapshot retention configuration for a mirror")
|
||||
.WithDescription("Returns the active snapshot retention policy for the specified mirror, including retention mode (keep_n), maximum kept snapshot count, and whether pinned snapshots are excluded from pruning.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
mirrors.MapPut("/{mirrorId}/retention", UpdateRetentionConfig)
|
||||
.WithName("UpdateMirrorRetentionConfig")
|
||||
.WithSummary("Update snapshot retention configuration for a mirror")
|
||||
.WithDescription("Sets the snapshot retention policy for a specific feed mirror. Supports keep_n mode with configurable count and pin exclusion. Existing snapshots exceeding the new limit are pruned on the next scheduled cleanup.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// Snapshot operations (by snapshotId)
|
||||
var snapshots = app.MapGroup("/api/v1/concelier/snapshots")
|
||||
.WithTags("FeedSnapshots");
|
||||
.WithTags("FeedSnapshots")
|
||||
.RequireAuthorization("Concelier.Advisories.Read")
|
||||
.RequireTenant();
|
||||
|
||||
snapshots.MapGet("/{snapshotId}", GetSnapshot);
|
||||
snapshots.MapPost("/{snapshotId}/download", DownloadSnapshot);
|
||||
snapshots.MapPatch("/{snapshotId}", PinSnapshot);
|
||||
snapshots.MapDelete("/{snapshotId}", DeleteSnapshot);
|
||||
snapshots.MapGet("/{snapshotId}", GetSnapshot)
|
||||
.WithName("GetMirrorFeedSnapshot")
|
||||
.WithSummary("Get feed snapshot details")
|
||||
.WithDescription("Returns the metadata and download URL for a specific feed snapshot identified by its snapshot ID, including size, checksums, record count, and pinned state.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
snapshots.MapPost("/{snapshotId}/download", DownloadSnapshot)
|
||||
.WithName("DownloadFeedSnapshot")
|
||||
.WithSummary("Download a feed snapshot")
|
||||
.WithDescription("Initiates or polls the download of a specific feed snapshot bundle. Returns download progress including bytes downloaded, total size, and completion percentage.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
snapshots.MapPatch("/{snapshotId}", PinSnapshot)
|
||||
.WithName("PinFeedSnapshot")
|
||||
.WithSummary("Pin or unpin a feed snapshot")
|
||||
.WithDescription("Sets the pinned state of a feed snapshot, protecting it from automatic retention-based pruning when pinned. Pinned snapshots are retained indefinitely regardless of the mirror's retention policy.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
snapshots.MapDelete("/{snapshotId}", DeleteSnapshot)
|
||||
.WithName("DeleteFeedSnapshot")
|
||||
.WithSummary("Delete a feed snapshot")
|
||||
.WithDescription("Permanently removes a feed snapshot and its associated bundle files from storage. Pinned snapshots should be unpinned before deletion. Returns 404 if the snapshot does not exist.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// Bundle management
|
||||
var bundles = app.MapGroup("/api/v1/concelier/bundles")
|
||||
.WithTags("AirGapBundles");
|
||||
.WithTags("AirGapBundles")
|
||||
.RequireAuthorization("Concelier.Advisories.Read")
|
||||
.RequireTenant();
|
||||
|
||||
bundles.MapGet(string.Empty, ListBundles);
|
||||
bundles.MapGet("/{bundleId}", GetBundle);
|
||||
bundles.MapPost(string.Empty, CreateBundle);
|
||||
bundles.MapDelete("/{bundleId}", DeleteBundle);
|
||||
bundles.MapPost("/{bundleId}/download", DownloadBundle);
|
||||
bundles.MapGet(string.Empty, ListBundles)
|
||||
.WithName("ListAirGapBundles")
|
||||
.WithSummary("List air-gap bundles")
|
||||
.WithDescription("Returns all air-gap advisory bundles with their status, included feeds, snapshot IDs, feed versions, size, and checksums. Bundles in 'ready' status include download and manifest URLs.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
bundles.MapGet("/{bundleId}", GetBundle)
|
||||
.WithName("GetAirGapBundle")
|
||||
.WithSummary("Get air-gap bundle details")
|
||||
.WithDescription("Returns the full record for a specific air-gap bundle identified by bundle ID, including status, included feeds, snapshot references, feed version map, size, checksums, and download URLs.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
bundles.MapPost(string.Empty, CreateBundle)
|
||||
.WithName("CreateAirGapBundle")
|
||||
.WithSummary("Create a new air-gap bundle")
|
||||
.WithDescription("Creates a new air-gap advisory bundle aggregating the specified feeds and snapshots. The bundle starts in 'pending' status and transitions to 'ready' once the packaging job completes.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
bundles.MapDelete("/{bundleId}", DeleteBundle)
|
||||
.WithName("DeleteAirGapBundle")
|
||||
.WithSummary("Delete an air-gap bundle")
|
||||
.WithDescription("Permanently removes an air-gap bundle and its associated package files. Returns 404 if the bundle does not exist. Active downloads of the bundle may fail after deletion.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
bundles.MapPost("/{bundleId}/download", DownloadBundle)
|
||||
.WithName("DownloadAirGapBundle")
|
||||
.WithSummary("Download an air-gap bundle")
|
||||
.WithDescription("Initiates or polls the download of a specific air-gap bundle. Returns progress including bytes downloaded, total size, and completion percentage for use by offline deployment tooling.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// Import operations
|
||||
var imports = app.MapGroup("/api/v1/concelier/imports")
|
||||
.WithTags("AirGapImports");
|
||||
.WithTags("AirGapImports")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest")
|
||||
.RequireTenant();
|
||||
|
||||
imports.MapPost("/validate", ValidateImport);
|
||||
imports.MapPost("/", StartImport);
|
||||
imports.MapGet("/{importId}", GetImportProgress);
|
||||
imports.MapPost("/validate", ValidateImport)
|
||||
.WithName("ValidateAirGapImport")
|
||||
.WithSummary("Validate an air-gap import bundle before importing")
|
||||
.WithDescription("Validates an air-gap bundle before importing by checking checksums, signature, and manifest integrity. Returns the list of feeds and snapshots found, total record count, any validation errors, and warnings. The canImport flag indicates whether the bundle is safe to import.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
imports.MapPost("/", StartImport)
|
||||
.WithName("StartAirGapImport")
|
||||
.WithSummary("Start an air-gap bundle import")
|
||||
.WithDescription("Initiates the import of a previously validated air-gap bundle into the advisory database. Returns an import ID that can be polled via the progress endpoint. Import processes feeds sequentially and updates all affected interest scores on completion.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
imports.MapGet("/{importId}", GetImportProgress)
|
||||
.WithName("GetAirGapImportProgress")
|
||||
.WithSummary("Get air-gap import progress")
|
||||
.WithDescription("Returns the current progress of an air-gap import operation identified by import ID, including current feed being processed, feeds completed, records imported, and overall completion percentage.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// Version lock operations
|
||||
var versionLocks = app.MapGroup("/api/v1/concelier/version-locks")
|
||||
.WithTags("VersionLocks");
|
||||
.WithTags("VersionLocks")
|
||||
.RequireAuthorization("Concelier.Advisories.Read")
|
||||
.RequireTenant();
|
||||
|
||||
versionLocks.MapGet(string.Empty, ListVersionLocks);
|
||||
versionLocks.MapGet("/{feedType}", GetVersionLock);
|
||||
versionLocks.MapPut("/{feedType}", SetVersionLock);
|
||||
versionLocks.MapDelete("/{lockId}", RemoveVersionLock);
|
||||
versionLocks.MapGet(string.Empty, ListVersionLocks)
|
||||
.WithName("ListVersionLocks")
|
||||
.WithSummary("List all version locks")
|
||||
.WithDescription("Returns all active feed version locks with their lock mode (pinned, latest, date-locked), pinned snapshot or version, and the operator who created the lock. Version locks prevent automatic updates to pinned feeds.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
versionLocks.MapGet("/{feedType}", GetVersionLock)
|
||||
.WithName("GetVersionLock")
|
||||
.WithSummary("Get version lock for a feed type")
|
||||
.WithDescription("Returns the current version lock for the specified feed type, or null if no lock exists. Includes the lock mode, pinned version or snapshot reference, and creation metadata.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
versionLocks.MapPut("/{feedType}", SetVersionLock)
|
||||
.WithName("SetVersionLock")
|
||||
.WithSummary("Set or update a version lock for a feed type")
|
||||
.WithDescription("Creates or replaces the version lock for the specified feed type. Supports pinned-version, pinned-snapshot, date-locked, and latest-always modes. Active locks prevent the mirror sync scheduler from advancing the feed beyond the locked point.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
versionLocks.MapDelete("/{lockId}", RemoveVersionLock)
|
||||
.WithName("RemoveVersionLock")
|
||||
.WithSummary("Remove a version lock")
|
||||
.WithDescription("Removes an existing version lock by lock ID, allowing the mirror sync scheduler to resume automatic feed updates for the associated feed type. Returns 404 if the lock does not exist.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// Offline status
|
||||
app.MapGet("/api/v1/concelier/offline-status", GetOfflineSyncStatus)
|
||||
.WithTags("OfflineStatus");
|
||||
.WithTags("OfflineStatus")
|
||||
.WithName("GetOfflineSyncStatus")
|
||||
.WithSummary("Get offline/air-gap sync status")
|
||||
.WithDescription("Returns the current offline synchronization state across all feed mirrors, including per-feed record counts, staleness indicators, total storage usage, and actionable recommendations for feeds that require attention.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read")
|
||||
.RequireTenant();
|
||||
}
|
||||
|
||||
// ---- Mirror Handlers ----
|
||||
|
||||
@@ -12,6 +12,7 @@ using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
using StellaOps.Replay.Core.FeedSnapshot;
|
||||
@@ -33,49 +34,57 @@ internal static class FeedSnapshotEndpointExtensions
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/feeds/snapshot")
|
||||
.WithTags("FeedSnapshot")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireTenant();
|
||||
|
||||
// POST /api/v1/feeds/snapshot - Create atomic snapshot
|
||||
group.MapPost("/", CreateSnapshotAsync)
|
||||
.WithName("CreateFeedSnapshot")
|
||||
.WithSummary("Create an atomic feed snapshot")
|
||||
.WithDescription("Creates an atomic snapshot of all registered feed sources with a composite digest.");
|
||||
.WithDescription("Creates an atomic, point-in-time snapshot of all registered feed sources, computing per-source digests and a composite digest for deterministic replay and offline-first bundle generation. Optionally scoped to a subset of sources via the sources field.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// GET /api/v1/feeds/snapshot - List available snapshots
|
||||
group.MapGet("/", ListSnapshotsAsync)
|
||||
.WithName("ListFeedSnapshots")
|
||||
.WithSummary("List available feed snapshots")
|
||||
.WithDescription("Returns a list of available feed snapshots with metadata.");
|
||||
.WithDescription("Returns a paginated list of available feed snapshots ordered by creation time, including composite digest, label, source count, and total item count per snapshot.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// GET /api/v1/feeds/snapshot/{snapshotId} - Get snapshot details
|
||||
group.MapGet("/{snapshotId}", GetSnapshotAsync)
|
||||
.WithName("GetFeedSnapshot")
|
||||
.WithSummary("Get feed snapshot details")
|
||||
.WithDescription("Returns detailed information about a specific feed snapshot.");
|
||||
.WithDescription("Returns the full details of a specific feed snapshot identified by its snapshot ID, including per-source digests, item counts, and creation timestamps.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// GET /api/v1/feeds/snapshot/{snapshotId}/export - Export snapshot bundle
|
||||
group.MapGet("/{snapshotId}/export", ExportSnapshotAsync)
|
||||
.WithName("ExportFeedSnapshot")
|
||||
.WithSummary("Export feed snapshot bundle")
|
||||
.WithDescription("Downloads the snapshot bundle as a compressed archive for offline use.");
|
||||
.WithDescription("Streams the snapshot bundle as a compressed archive (zstd by default, gzip or uncompressed via the format query parameter) for offline and air-gap use. Includes manifest and checksum files.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// POST /api/v1/feeds/snapshot/import - Import snapshot bundle
|
||||
group.MapPost("/import", ImportSnapshotAsync)
|
||||
.WithName("ImportFeedSnapshot")
|
||||
.WithSummary("Import feed snapshot bundle")
|
||||
.WithDescription("Imports a snapshot bundle from a compressed archive.");
|
||||
.WithDescription("Imports a snapshot bundle from a compressed archive uploaded as a multipart file, optionally validating per-source digests before registering the snapshot. The resulting snapshot ID is returned in the Location header.")
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// GET /api/v1/feeds/snapshot/{snapshotId}/validate - Validate snapshot
|
||||
group.MapGet("/{snapshotId}/validate", ValidateSnapshotAsync)
|
||||
.WithName("ValidateFeedSnapshot")
|
||||
.WithSummary("Validate feed snapshot integrity")
|
||||
.WithDescription("Validates the integrity of a feed snapshot against current feed state.");
|
||||
.WithDescription("Validates a stored feed snapshot against the current live feed state, detecting source-level digest drift. Returns which sources have drifted and the item-level add/remove/modify counts for each.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// GET /api/v1/feeds/sources - List registered feed sources
|
||||
group.MapGet("/sources", ListSourcesAsync)
|
||||
.WithName("ListFeedSources")
|
||||
.WithSummary("List registered feed sources")
|
||||
.WithDescription("Returns a list of registered feed sources available for snapshots.");
|
||||
.WithDescription("Returns the identifiers of all feed sources currently registered with the snapshot coordinator and eligible for inclusion in new snapshots.")
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateSnapshotAsync(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Concelier.Interest;
|
||||
using StellaOps.Concelier.Interest.Models;
|
||||
|
||||
@@ -24,7 +25,8 @@ internal static class InterestScoreEndpointExtensions
|
||||
public static void MapInterestScoreEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1")
|
||||
.WithTags("Interest Scores");
|
||||
.WithTags("Interest Scores")
|
||||
.RequireTenant();
|
||||
|
||||
// GET /api/v1/canonical/{id}/score - Get interest score for a canonical advisory
|
||||
group.MapGet("/canonical/{id:guid}/score", async (
|
||||
@@ -40,8 +42,10 @@ internal static class InterestScoreEndpointExtensions
|
||||
})
|
||||
.WithName("GetInterestScore")
|
||||
.WithSummary("Get interest score for a canonical advisory")
|
||||
.WithDescription("Returns the current interest score and tier for a canonical advisory, including the scored reasons and the last build in which the advisory's affected component was observed. Returns 404 if no score has been computed yet.")
|
||||
.Produces<InterestScoreResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScoreReadPolicy);
|
||||
|
||||
// GET /api/v1/scores - Query interest scores
|
||||
group.MapGet("/scores", async (
|
||||
@@ -77,7 +81,9 @@ internal static class InterestScoreEndpointExtensions
|
||||
})
|
||||
.WithName("QueryInterestScores")
|
||||
.WithSummary("Query interest scores with optional filtering")
|
||||
.Produces<InterestScoreListResponse>(StatusCodes.Status200OK);
|
||||
.WithDescription("Returns a paginated list of canonical advisory interest scores, optionally filtered by minimum and maximum score thresholds. Results include score tier, contributing reasons, and computation timestamp.")
|
||||
.Produces<InterestScoreListResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScoreReadPolicy);
|
||||
|
||||
// GET /api/v1/scores/distribution - Get score distribution statistics
|
||||
group.MapGet("/scores/distribution", async (
|
||||
@@ -99,7 +105,9 @@ internal static class InterestScoreEndpointExtensions
|
||||
})
|
||||
.WithName("GetScoreDistribution")
|
||||
.WithSummary("Get score distribution statistics")
|
||||
.Produces<ScoreDistributionResponse>(StatusCodes.Status200OK);
|
||||
.WithDescription("Returns aggregate statistics across all computed interest scores, including counts per tier (high/medium/low/none), total scored advisories, average score, and median score. Used for dashboarding and SLA reporting.")
|
||||
.Produces<ScoreDistributionResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScoreReadPolicy);
|
||||
|
||||
// POST /api/v1/canonical/{id}/score/compute - Compute score for a canonical
|
||||
group.MapPost("/canonical/{id:guid}/score/compute", async (
|
||||
@@ -114,7 +122,9 @@ internal static class InterestScoreEndpointExtensions
|
||||
})
|
||||
.WithName("ComputeInterestScore")
|
||||
.WithSummary("Compute and update interest score for a canonical advisory")
|
||||
.Produces<InterestScoreResponse>(StatusCodes.Status200OK);
|
||||
.WithDescription("Triggers an on-demand interest score computation for a single canonical advisory and persists the result. Useful for forcing a score refresh after SBOM registration, reachability updates, or manual investigation.")
|
||||
.Produces<InterestScoreResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScoreAdminPolicy);
|
||||
|
||||
// POST /api/v1/scores/recalculate - Admin endpoint to trigger full recalculation
|
||||
group.MapPost("/scores/recalculate", async (
|
||||
@@ -144,7 +154,9 @@ internal static class InterestScoreEndpointExtensions
|
||||
})
|
||||
.WithName("RecalculateScores")
|
||||
.WithSummary("Trigger interest score recalculation (full or batch)")
|
||||
.Produces<RecalculateResponse>(StatusCodes.Status202Accepted);
|
||||
.WithDescription("Enqueues an interest score recalculation for either a specific set of canonical IDs (batch mode) or all advisories (full mode). Returns 202 Accepted immediately; actual updates occur asynchronously in the scoring background job.")
|
||||
.Produces<RecalculateResponse>(StatusCodes.Status202Accepted)
|
||||
.RequireAuthorization(ScoreAdminPolicy);
|
||||
|
||||
// POST /api/v1/scores/degrade - Admin endpoint to run stub degradation
|
||||
group.MapPost("/scores/degrade", async (
|
||||
@@ -167,7 +179,9 @@ internal static class InterestScoreEndpointExtensions
|
||||
})
|
||||
.WithName("DegradeToStubs")
|
||||
.WithSummary("Degrade low-interest advisories to stubs")
|
||||
.Produces<DegradeResponse>(StatusCodes.Status200OK);
|
||||
.WithDescription("Downgrades all canonical advisories whose interest score falls below the specified threshold (or the configured default) to stub representation, reducing storage footprint. Returns the count of advisories degraded.")
|
||||
.Produces<DegradeResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScoreAdminPolicy);
|
||||
|
||||
// POST /api/v1/scores/restore - Admin endpoint to restore stubs
|
||||
group.MapPost("/scores/restore", async (
|
||||
@@ -190,7 +204,9 @@ internal static class InterestScoreEndpointExtensions
|
||||
})
|
||||
.WithName("RestoreFromStubs")
|
||||
.WithSummary("Restore stubs with increased interest scores")
|
||||
.Produces<RestoreResponse>(StatusCodes.Status200OK);
|
||||
.WithDescription("Promotes stub advisories whose interest score now exceeds the specified restoration threshold back to full canonical representation. Typically triggered after new SBOM registrations or reachability discoveries raise scores above the stub cutoff.")
|
||||
.Produces<RestoreResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScoreAdminPolicy);
|
||||
}
|
||||
|
||||
private static InterestScoreResponse MapToResponse(InterestScore score) => new()
|
||||
|
||||
@@ -3,6 +3,7 @@ using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Concelier.WebService.Diagnostics;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
@@ -49,7 +50,11 @@ internal static class MirrorEndpointExtensions
|
||||
}
|
||||
|
||||
return await WriteFileAsync(context, path, "application/json").ConfigureAwait(false);
|
||||
});
|
||||
})
|
||||
.WithName("GetMirrorIndex")
|
||||
.WithSummary("Get mirror index")
|
||||
.WithDescription("Serves the mirror index JSON file listing all available advisory export files. Respects per-mirror authentication settings and rate limits. Clients should poll this endpoint to discover available export bundles.")
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/concelier/exports/{**relativePath}", async (
|
||||
string? relativePath,
|
||||
@@ -91,7 +96,11 @@ internal static class MirrorEndpointExtensions
|
||||
|
||||
var contentType = ResolveContentType(path);
|
||||
return await WriteFileAsync(context, path, contentType).ConfigureAwait(false);
|
||||
});
|
||||
})
|
||||
.WithName("DownloadMirrorFile")
|
||||
.WithSummary("Download a mirror export file")
|
||||
.WithDescription("Serves a specific advisory export file from the mirror by relative path. Content-Type is resolved from the file extension (JSON, JWS, or octet-stream). Respects per-domain authentication and download rate limits.")
|
||||
.RequireTenant();
|
||||
}
|
||||
|
||||
private static ConcelierOptions.MirrorDomainOptions? FindDomain(ConcelierOptions.MirrorOptions mirrorOptions, string? domainId)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Concelier.SbomIntegration;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
@@ -21,7 +22,8 @@ internal static class SbomEndpointExtensions
|
||||
public static void MapSbomEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1")
|
||||
.WithTags("SBOM Learning");
|
||||
.WithTags("SBOM Learning")
|
||||
.RequireTenant();
|
||||
|
||||
// POST /api/v1/learn/sbom - Register and learn from an SBOM
|
||||
group.MapPost("/learn/sbom", async (
|
||||
@@ -57,8 +59,10 @@ internal static class SbomEndpointExtensions
|
||||
})
|
||||
.WithName("LearnSbom")
|
||||
.WithSummary("Register SBOM and update interest scores for affected advisories")
|
||||
.WithDescription("Registers an SBOM by digest, extracts its component PURLs, matches them against the canonical advisory database, and updates the interest score for every matched advisory. Accepts optional reachability and deployment maps to weight reachable/deployed components more heavily.")
|
||||
.Produces<SbomLearnResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// GET /api/v1/sboms/{digest}/affected - Get advisories affecting an SBOM
|
||||
group.MapGet("/sboms/{digest}/affected", async (
|
||||
@@ -96,8 +100,10 @@ internal static class SbomEndpointExtensions
|
||||
})
|
||||
.WithName("GetSbomAffected")
|
||||
.WithSummary("Get advisories affecting an SBOM")
|
||||
.WithDescription("Returns all canonical advisories that matched components in the specified SBOM, identified by digest. Each match includes the PURL, reachability and deployment status, confidence score, and the matching method used.")
|
||||
.Produces<SbomAffectedResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// GET /api/v1/sboms - List registered SBOMs
|
||||
group.MapGet("/sboms", async (
|
||||
@@ -136,7 +142,9 @@ internal static class SbomEndpointExtensions
|
||||
})
|
||||
.WithName("ListSboms")
|
||||
.WithSummary("List registered SBOMs with pagination")
|
||||
.Produces<SbomListResponse>(StatusCodes.Status200OK);
|
||||
.WithDescription("Returns a paginated list of all registered SBOMs with summary information including format, component count, affected advisory count, and last match timestamp. Optionally filtered by tenant ID.")
|
||||
.Produces<SbomListResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// GET /api/v1/sboms/{digest} - Get SBOM registration details
|
||||
group.MapGet("/sboms/{digest}", async (
|
||||
@@ -169,8 +177,10 @@ internal static class SbomEndpointExtensions
|
||||
})
|
||||
.WithName("GetSbom")
|
||||
.WithSummary("Get SBOM registration details")
|
||||
.WithDescription("Returns the full registration record for an SBOM identified by its digest, including format, spec version, component count, source, tenant, and last advisory match timestamp.")
|
||||
.Produces<SbomDetailResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
|
||||
// DELETE /api/v1/sboms/{digest} - Unregister an SBOM
|
||||
group.MapDelete("/sboms/{digest}", async (
|
||||
@@ -183,7 +193,9 @@ internal static class SbomEndpointExtensions
|
||||
})
|
||||
.WithName("UnregisterSbom")
|
||||
.WithSummary("Unregister an SBOM")
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
.WithDescription("Removes the SBOM registration identified by digest from the registry, along with all associated PURL-to-canonical match records. Does not modify the interest scores of previously matched advisories.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// POST /api/v1/sboms/{digest}/rematch - Rematch SBOM against current advisories
|
||||
group.MapPost("/sboms/{digest}/rematch", async (
|
||||
@@ -210,8 +222,10 @@ internal static class SbomEndpointExtensions
|
||||
})
|
||||
.WithName("RematchSbom")
|
||||
.WithSummary("Re-match SBOM against current advisory database")
|
||||
.WithDescription("Re-runs PURL matching for an existing SBOM against the current state of the canonical advisory database and updates match records. Returns the previous and new affected advisory counts so callers can detect newly introduced vulnerabilities.")
|
||||
.Produces<SbomRematchResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// PATCH /api/v1/sboms/{digest} - Incrementally update SBOM (add/remove components)
|
||||
group.MapPatch("/sboms/{digest}", async (
|
||||
@@ -253,8 +267,10 @@ internal static class SbomEndpointExtensions
|
||||
})
|
||||
.WithName("UpdateSbomDelta")
|
||||
.WithSummary("Incrementally update SBOM components (add/remove)")
|
||||
.WithDescription("Applies an incremental delta to a registered SBOM, adding or removing component PURLs and updating the reachability and deployment maps. After the update, re-runs advisory matching and interest score updates only for the affected components. Supports full replacement mode.")
|
||||
.Produces<SbomDeltaResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization("Concelier.Advisories.Ingest");
|
||||
|
||||
// GET /api/v1/sboms/stats - Get SBOM registry statistics
|
||||
group.MapGet("/sboms/stats", async (
|
||||
@@ -275,7 +291,9 @@ internal static class SbomEndpointExtensions
|
||||
})
|
||||
.WithName("GetSbomStats")
|
||||
.WithSummary("Get SBOM registry statistics")
|
||||
.Produces<SbomStatsResponse>(StatusCodes.Status200OK);
|
||||
.WithDescription("Returns aggregate statistics for the SBOM registry, including total registered SBOMs, total unique PURLs, total advisory matches, number of SBOMs with at least one match, and average matches per SBOM. Optionally scoped to a specific tenant.")
|
||||
.Produces<SbomStatsResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization("Concelier.Advisories.Read");
|
||||
}
|
||||
|
||||
private static SbomFormat ParseSbomFormat(string? format)
|
||||
|
||||
@@ -22,6 +22,7 @@ using StellaOps.Aoc.AspNetCore.Routing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Concelier.Core.Aoc;
|
||||
using StellaOps.Concelier.Core.Attestation;
|
||||
using StellaOps.Concelier.Core.Diagnostics;
|
||||
@@ -83,6 +84,10 @@ public partial class Program
|
||||
private const string AdvisoryIngestPolicyName = "Concelier.Advisories.Ingest";
|
||||
private const string AdvisoryReadPolicyName = "Concelier.Advisories.Read";
|
||||
private const string AocVerifyPolicyName = "Concelier.Aoc.Verify";
|
||||
private const string CanonicalReadPolicyName = "Concelier.Canonical.Read";
|
||||
private const string CanonicalIngestPolicyName = "Concelier.Canonical.Ingest";
|
||||
private const string InterestReadPolicyName = "Concelier.Interest.Read";
|
||||
private const string InterestAdminPolicyName = "Concelier.Interest.Admin";
|
||||
public const string TenantHeaderName = "X-Stella-Tenant";
|
||||
|
||||
public static async Task Main(string[] args)
|
||||
@@ -824,6 +829,10 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddStellaOpsScopePolicy(AdvisoryIngestPolicyName, StellaOpsScopes.AdvisoryIngest);
|
||||
options.AddStellaOpsScopePolicy(AdvisoryReadPolicyName, StellaOpsScopes.AdvisoryRead);
|
||||
options.AddStellaOpsScopePolicy(AocVerifyPolicyName, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.AocVerify);
|
||||
options.AddStellaOpsScopePolicy(CanonicalReadPolicyName, StellaOpsScopes.AdvisoryRead);
|
||||
options.AddStellaOpsScopePolicy(CanonicalIngestPolicyName, StellaOpsScopes.AdvisoryIngest);
|
||||
options.AddStellaOpsScopePolicy(InterestReadPolicyName, StellaOpsScopes.VulnView);
|
||||
options.AddStellaOpsScopePolicy(InterestAdminPolicyName, StellaOpsScopes.AdvisoryIngest);
|
||||
});
|
||||
|
||||
var pluginHostOptions = BuildPluginOptions(concelierOptions, builder.Environment.ContentRootPath);
|
||||
@@ -831,6 +840,7 @@ builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
builder.Services.AddStellaOpsTenantServices();
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("concelier");
|
||||
var app = builder.Build();
|
||||
@@ -898,6 +908,7 @@ if (authorityConfigured)
|
||||
});
|
||||
|
||||
app.UseAuthorization();
|
||||
app.UseStellaOpsTenantMiddleware();
|
||||
}
|
||||
|
||||
// Stella Router integration
|
||||
@@ -1019,7 +1030,7 @@ if (swaggerEnabled)
|
||||
var orchestratorGroup = app.MapGroup("/internal/orch");
|
||||
if (authorityConfigured)
|
||||
{
|
||||
orchestratorGroup.RequireAuthorization();
|
||||
orchestratorGroup.RequireAuthorization(JobsPolicyName);
|
||||
}
|
||||
|
||||
orchestratorGroup.MapPost("/registry", async (
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// <auto-generated />
|
||||
// Compiled model stub for Concelier EF Core.
|
||||
// This will be regenerated by `dotnet ef dbcontext optimize` when a live DB is available.
|
||||
// The runtime factory guards against empty stubs using GetEntityTypes().Any().
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[DbContext(typeof(ConcelierDbContext))]
|
||||
public partial class ConcelierDbContextModel : RuntimeModel
|
||||
{
|
||||
private static readonly bool _useOldBehavior31751 =
|
||||
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
|
||||
|
||||
static ConcelierDbContextModel()
|
||||
{
|
||||
var model = new ConcelierDbContextModel();
|
||||
|
||||
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 = (ConcelierDbContextModel)model.FinalizeModel();
|
||||
}
|
||||
|
||||
private static ConcelierDbContextModel _instance;
|
||||
public static IModel Instance => _instance;
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,685 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for Concelier module.
|
||||
/// This is a stub that will be scaffolded from the PostgreSQL database.
|
||||
/// EF Core DbContext for the Concelier module.
|
||||
/// Covers both the vuln and concelier schemas.
|
||||
/// Scaffolded from SQL migrations 001-005.
|
||||
/// </summary>
|
||||
public class ConcelierDbContext : DbContext
|
||||
public partial class ConcelierDbContext : DbContext
|
||||
{
|
||||
public ConcelierDbContext(DbContextOptions<ConcelierDbContext> options)
|
||||
private readonly string _schemaName;
|
||||
|
||||
public ConcelierDbContext(DbContextOptions<ConcelierDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "vuln"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
// ---- vuln schema DbSets ----
|
||||
public virtual DbSet<SourceEntity> Sources { get; set; }
|
||||
public virtual DbSet<FeedSnapshotEntity> FeedSnapshots { get; set; }
|
||||
public virtual DbSet<AdvisorySnapshotEntity> AdvisorySnapshots { get; set; }
|
||||
public virtual DbSet<AdvisoryEntity> Advisories { get; set; }
|
||||
public virtual DbSet<AdvisoryAliasEntity> AdvisoryAliases { get; set; }
|
||||
public virtual DbSet<AdvisoryCvssEntity> AdvisoryCvss { get; set; }
|
||||
public virtual DbSet<AdvisoryAffectedEntity> AdvisoryAffected { get; set; }
|
||||
public virtual DbSet<AdvisoryReferenceEntity> AdvisoryReferences { get; set; }
|
||||
public virtual DbSet<AdvisoryCreditEntity> AdvisoryCredits { get; set; }
|
||||
public virtual DbSet<AdvisoryWeaknessEntity> AdvisoryWeaknesses { get; set; }
|
||||
public virtual DbSet<KevFlagEntity> KevFlags { get; set; }
|
||||
public virtual DbSet<SourceStateEntity> SourceStates { get; set; }
|
||||
public virtual DbSet<MergeEventEntity> MergeEvents { get; set; }
|
||||
public virtual DbSet<AdvisoryLinksetCacheEntity> LinksetCache { get; set; }
|
||||
public virtual DbSet<SyncLedgerEntity> SyncLedger { get; set; }
|
||||
public virtual DbSet<SitePolicyEntity> SitePolicy { get; set; }
|
||||
public virtual DbSet<AdvisoryCanonicalEntity> AdvisoryCanonicals { get; set; }
|
||||
public virtual DbSet<AdvisorySourceEdgeEntity> AdvisorySourceEdges { get; set; }
|
||||
public virtual DbSet<ProvenanceScopeEntity> ProvenanceScopes { get; set; }
|
||||
|
||||
// ---- vuln schema DbSets (additional) ----
|
||||
public virtual DbSet<InterestScoreEntity> InterestScores { get; set; }
|
||||
|
||||
// ---- concelier schema DbSets ----
|
||||
public virtual DbSet<DocumentRecordEntity> SourceDocuments { get; set; }
|
||||
public virtual DbSet<DtoRecordEntity> Dtos { get; set; }
|
||||
public virtual DbSet<ExportStateEntity> ExportStates { get; set; }
|
||||
public virtual DbSet<PsirtFlagEntity> PsirtFlags { get; set; }
|
||||
public virtual DbSet<JpFlagEntity> JpFlags { get; set; }
|
||||
public virtual DbSet<ChangeHistoryEntity> ChangeHistory { get; set; }
|
||||
public virtual DbSet<SbomDocumentEntity> SbomDocuments { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("vuln");
|
||||
base.OnModelCreating(modelBuilder);
|
||||
var schemaName = _schemaName;
|
||||
const string concelierSchema = "concelier";
|
||||
|
||||
// ================================================================
|
||||
// vuln.sources
|
||||
// ================================================================
|
||||
modelBuilder.Entity<SourceEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("sources_pkey");
|
||||
entity.ToTable("sources", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.Enabled, e.Priority }, "idx_sources_enabled")
|
||||
.IsDescending(false, true);
|
||||
entity.HasIndex(e => e.Key).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.Key).HasColumnName("key");
|
||||
entity.Property(e => e.Name).HasColumnName("name");
|
||||
entity.Property(e => e.SourceType).HasColumnName("source_type");
|
||||
entity.Property(e => e.Url).HasColumnName("url");
|
||||
entity.Property(e => e.Priority).HasColumnName("priority");
|
||||
entity.Property(e => e.Enabled).HasColumnName("enabled").HasDefaultValue(true);
|
||||
entity.Property(e => e.Config).HasColumnName("config").HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
|
||||
entity.Property(e => e.Metadata).HasColumnName("metadata").HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.feed_snapshots
|
||||
// ================================================================
|
||||
modelBuilder.Entity<FeedSnapshotEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("feed_snapshots_pkey");
|
||||
entity.ToTable("feed_snapshots", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.SourceId, "idx_feed_snapshots_source");
|
||||
entity.HasIndex(e => e.CreatedAt, "idx_feed_snapshots_created");
|
||||
entity.HasIndex(e => new { e.SourceId, e.SnapshotId }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.SnapshotId).HasColumnName("snapshot_id");
|
||||
entity.Property(e => e.AdvisoryCount).HasColumnName("advisory_count");
|
||||
entity.Property(e => e.Checksum).HasColumnName("checksum");
|
||||
entity.Property(e => e.Metadata).HasColumnName("metadata").HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.advisory_snapshots
|
||||
// ================================================================
|
||||
modelBuilder.Entity<AdvisorySnapshotEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("advisory_snapshots_pkey");
|
||||
entity.ToTable("advisory_snapshots", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.FeedSnapshotId, "idx_advisory_snapshots_feed");
|
||||
entity.HasIndex(e => e.AdvisoryKey, "idx_advisory_snapshots_key");
|
||||
entity.HasIndex(e => new { e.FeedSnapshotId, e.AdvisoryKey }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.FeedSnapshotId).HasColumnName("feed_snapshot_id");
|
||||
entity.Property(e => e.AdvisoryKey).HasColumnName("advisory_key");
|
||||
entity.Property(e => e.ContentHash).HasColumnName("content_hash");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.advisories
|
||||
// ================================================================
|
||||
modelBuilder.Entity<AdvisoryEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("advisories_pkey");
|
||||
entity.ToTable("advisories", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.AdvisoryKey).IsUnique();
|
||||
entity.HasIndex(e => e.PrimaryVulnId, "idx_advisories_vuln_id");
|
||||
entity.HasIndex(e => e.SourceId, "idx_advisories_source");
|
||||
entity.HasIndex(e => e.Severity, "idx_advisories_severity");
|
||||
entity.HasIndex(e => e.PublishedAt, "idx_advisories_published");
|
||||
entity.HasIndex(e => e.ModifiedAt, "idx_advisories_modified");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.AdvisoryKey).HasColumnName("advisory_key");
|
||||
entity.Property(e => e.PrimaryVulnId).HasColumnName("primary_vuln_id");
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.Title).HasColumnName("title");
|
||||
entity.Property(e => e.Summary).HasColumnName("summary");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.Severity).HasColumnName("severity");
|
||||
entity.Property(e => e.PublishedAt).HasColumnName("published_at");
|
||||
entity.Property(e => e.ModifiedAt).HasColumnName("modified_at");
|
||||
entity.Property(e => e.WithdrawnAt).HasColumnName("withdrawn_at");
|
||||
entity.Property(e => e.Provenance).HasColumnName("provenance").HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
|
||||
entity.Property(e => e.RawPayload).HasColumnName("raw_payload").HasColumnType("jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
|
||||
|
||||
// Generated/computed columns and tsvector are not mapped; DB triggers handle them.
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.advisory_aliases
|
||||
// ================================================================
|
||||
modelBuilder.Entity<AdvisoryAliasEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("advisory_aliases_pkey");
|
||||
entity.ToTable("advisory_aliases", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.AdvisoryId, "idx_advisory_aliases_advisory");
|
||||
entity.HasIndex(e => new { e.AliasType, e.AliasValue }, "idx_advisory_aliases_value");
|
||||
entity.HasIndex(e => new { e.AdvisoryId, e.AliasType, e.AliasValue }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
|
||||
entity.Property(e => e.AliasType).HasColumnName("alias_type");
|
||||
entity.Property(e => e.AliasValue).HasColumnName("alias_value");
|
||||
entity.Property(e => e.IsPrimary).HasColumnName("is_primary").HasDefaultValue(false);
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.advisory_cvss
|
||||
// ================================================================
|
||||
modelBuilder.Entity<AdvisoryCvssEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("advisory_cvss_pkey");
|
||||
entity.ToTable("advisory_cvss", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.AdvisoryId, "idx_advisory_cvss_advisory");
|
||||
entity.HasIndex(e => e.BaseScore, "idx_advisory_cvss_score").IsDescending();
|
||||
entity.HasIndex(e => new { e.AdvisoryId, e.CvssVersion, e.Source }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
|
||||
entity.Property(e => e.CvssVersion).HasColumnName("cvss_version");
|
||||
entity.Property(e => e.VectorString).HasColumnName("vector_string");
|
||||
entity.Property(e => e.BaseScore).HasColumnName("base_score").HasColumnType("numeric(3,1)");
|
||||
entity.Property(e => e.BaseSeverity).HasColumnName("base_severity");
|
||||
entity.Property(e => e.ExploitabilityScore).HasColumnName("exploitability_score").HasColumnType("numeric(3,1)");
|
||||
entity.Property(e => e.ImpactScore).HasColumnName("impact_score").HasColumnType("numeric(3,1)");
|
||||
entity.Property(e => e.Source).HasColumnName("source");
|
||||
entity.Property(e => e.IsPrimary).HasColumnName("is_primary").HasDefaultValue(false);
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.advisory_affected
|
||||
// ================================================================
|
||||
modelBuilder.Entity<AdvisoryAffectedEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("advisory_affected_pkey");
|
||||
entity.ToTable("advisory_affected", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.AdvisoryId, "idx_advisory_affected_advisory");
|
||||
entity.HasIndex(e => new { e.Ecosystem, e.PackageName }, "idx_advisory_affected_ecosystem");
|
||||
entity.HasIndex(e => e.Purl, "idx_advisory_affected_purl");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
|
||||
entity.Property(e => e.Ecosystem).HasColumnName("ecosystem");
|
||||
entity.Property(e => e.PackageName).HasColumnName("package_name");
|
||||
entity.Property(e => e.Purl).HasColumnName("purl");
|
||||
entity.Property(e => e.VersionRange).HasColumnName("version_range").HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
|
||||
entity.Property(e => e.VersionsAffected).HasColumnName("versions_affected");
|
||||
entity.Property(e => e.VersionsFixed).HasColumnName("versions_fixed");
|
||||
entity.Property(e => e.DatabaseSpecific).HasColumnName("database_specific").HasColumnType("jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
|
||||
// Generated columns purl_type and purl_name are DB-managed; not mapped.
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.advisory_references
|
||||
// ================================================================
|
||||
modelBuilder.Entity<AdvisoryReferenceEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("advisory_references_pkey");
|
||||
entity.ToTable("advisory_references", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.AdvisoryId, "idx_advisory_references_advisory");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
|
||||
entity.Property(e => e.RefType).HasColumnName("ref_type");
|
||||
entity.Property(e => e.Url).HasColumnName("url");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.advisory_credits
|
||||
// ================================================================
|
||||
modelBuilder.Entity<AdvisoryCreditEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("advisory_credits_pkey");
|
||||
entity.ToTable("advisory_credits", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.AdvisoryId, "idx_advisory_credits_advisory");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
|
||||
entity.Property(e => e.Name).HasColumnName("name");
|
||||
entity.Property(e => e.Contact).HasColumnName("contact");
|
||||
entity.Property(e => e.CreditType).HasColumnName("credit_type");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.advisory_weaknesses
|
||||
// ================================================================
|
||||
modelBuilder.Entity<AdvisoryWeaknessEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("advisory_weaknesses_pkey");
|
||||
entity.ToTable("advisory_weaknesses", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.AdvisoryId, "idx_advisory_weaknesses_advisory");
|
||||
entity.HasIndex(e => e.CweId, "idx_advisory_weaknesses_cwe");
|
||||
entity.HasIndex(e => new { e.AdvisoryId, e.CweId }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
|
||||
entity.Property(e => e.CweId).HasColumnName("cwe_id");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.Source).HasColumnName("source");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.kev_flags
|
||||
// ================================================================
|
||||
modelBuilder.Entity<KevFlagEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("kev_flags_pkey");
|
||||
entity.ToTable("kev_flags", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.AdvisoryId, "idx_kev_flags_advisory");
|
||||
entity.HasIndex(e => e.CveId, "idx_kev_flags_cve");
|
||||
entity.HasIndex(e => e.DateAdded, "idx_kev_flags_date");
|
||||
entity.HasIndex(e => new { e.AdvisoryId, e.CveId }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
|
||||
entity.Property(e => e.CveId).HasColumnName("cve_id");
|
||||
entity.Property(e => e.VendorProject).HasColumnName("vendor_project");
|
||||
entity.Property(e => e.Product).HasColumnName("product");
|
||||
entity.Property(e => e.VulnerabilityName).HasColumnName("vulnerability_name");
|
||||
entity.Property(e => e.DateAdded).HasColumnName("date_added");
|
||||
entity.Property(e => e.DueDate).HasColumnName("due_date");
|
||||
entity.Property(e => e.KnownRansomwareUse).HasColumnName("known_ransomware_use").HasDefaultValue(false);
|
||||
entity.Property(e => e.Notes).HasColumnName("notes");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.source_states
|
||||
// ================================================================
|
||||
modelBuilder.Entity<SourceStateEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("source_states_pkey");
|
||||
entity.ToTable("source_states", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.SourceId, "idx_source_states_source").IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.Cursor).HasColumnName("cursor");
|
||||
entity.Property(e => e.LastSyncAt).HasColumnName("last_sync_at");
|
||||
entity.Property(e => e.LastSuccessAt).HasColumnName("last_success_at");
|
||||
entity.Property(e => e.LastError).HasColumnName("last_error");
|
||||
entity.Property(e => e.SyncCount).HasColumnName("sync_count");
|
||||
entity.Property(e => e.ErrorCount).HasColumnName("error_count");
|
||||
entity.Property(e => e.Metadata).HasColumnName("metadata").HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.merge_events (partitioned table - map for query, not insert via EF)
|
||||
// ================================================================
|
||||
modelBuilder.Entity<MergeEventEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.Id, e.CreatedAt }).HasName("merge_events_pkey");
|
||||
entity.ToTable("merge_events", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.AdvisoryId, "ix_merge_events_part_advisory");
|
||||
entity.HasIndex(e => e.EventType, "ix_merge_events_part_event_type");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.UseIdentityByDefaultColumn();
|
||||
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.EventType).HasColumnName("event_type");
|
||||
entity.Property(e => e.OldValue).HasColumnName("old_value").HasColumnType("jsonb");
|
||||
entity.Property(e => e.NewValue).HasColumnName("new_value").HasColumnType("jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.lnm_linkset_cache
|
||||
// ================================================================
|
||||
modelBuilder.Entity<AdvisoryLinksetCacheEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("lnm_linkset_cache_pkey");
|
||||
entity.ToTable("lnm_linkset_cache", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.AdvisoryId, e.Source }, "uq_lnm_linkset_cache").IsUnique();
|
||||
entity.HasIndex(e => new { e.TenantId, e.CreatedAt, e.AdvisoryId, e.Source }, "idx_lnm_linkset_cache_order")
|
||||
.IsDescending(false, true, false, false);
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.Source).HasColumnName("source");
|
||||
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
|
||||
entity.Property(e => e.Observations).HasColumnName("observations");
|
||||
entity.Property(e => e.NormalizedJson).HasColumnName("normalized").HasColumnType("jsonb");
|
||||
entity.Property(e => e.ConflictsJson).HasColumnName("conflicts").HasColumnType("jsonb");
|
||||
entity.Property(e => e.ProvenanceJson).HasColumnName("provenance").HasColumnType("jsonb");
|
||||
entity.Property(e => e.Confidence).HasColumnName("confidence");
|
||||
entity.Property(e => e.BuiltByJobId).HasColumnName("built_by_job_id");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.sync_ledger
|
||||
// ================================================================
|
||||
modelBuilder.Entity<SyncLedgerEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("sync_ledger_pkey");
|
||||
entity.ToTable("sync_ledger", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.SiteId, "idx_sync_ledger_site");
|
||||
entity.HasIndex(e => new { e.SiteId, e.Cursor }, "uq_sync_ledger_site_cursor").IsUnique();
|
||||
entity.HasIndex(e => e.BundleHash, "uq_sync_ledger_bundle").IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.SiteId).HasColumnName("site_id");
|
||||
entity.Property(e => e.Cursor).HasColumnName("cursor");
|
||||
entity.Property(e => e.BundleHash).HasColumnName("bundle_hash");
|
||||
entity.Property(e => e.ItemsCount).HasColumnName("items_count");
|
||||
entity.Property(e => e.SignedAt).HasColumnName("signed_at");
|
||||
entity.Property(e => e.ImportedAt).HasColumnName("imported_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.site_policy
|
||||
// ================================================================
|
||||
modelBuilder.Entity<SitePolicyEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("site_policy_pkey");
|
||||
entity.ToTable("site_policy", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.SiteId).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.SiteId).HasColumnName("site_id");
|
||||
entity.Property(e => e.DisplayName).HasColumnName("display_name");
|
||||
entity.Property(e => e.AllowedSources).HasColumnName("allowed_sources");
|
||||
entity.Property(e => e.DeniedSources).HasColumnName("denied_sources");
|
||||
entity.Property(e => e.MaxBundleSizeMb).HasColumnName("max_bundle_size_mb").HasDefaultValue(100);
|
||||
entity.Property(e => e.MaxItemsPerBundle).HasColumnName("max_items_per_bundle").HasDefaultValue(10000);
|
||||
entity.Property(e => e.RequireSignature).HasColumnName("require_signature").HasDefaultValue(true);
|
||||
entity.Property(e => e.AllowedSigners).HasColumnName("allowed_signers");
|
||||
entity.Property(e => e.Enabled).HasColumnName("enabled").HasDefaultValue(true);
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.advisory_canonical
|
||||
// ================================================================
|
||||
modelBuilder.Entity<AdvisoryCanonicalEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("advisory_canonical_pkey");
|
||||
entity.ToTable("advisory_canonical", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.Cve, "idx_advisory_canonical_cve");
|
||||
entity.HasIndex(e => e.AffectsKey, "idx_advisory_canonical_affects");
|
||||
entity.HasIndex(e => e.MergeHash, "idx_advisory_canonical_merge_hash").IsUnique();
|
||||
entity.HasIndex(e => e.UpdatedAt, "idx_advisory_canonical_updated").IsDescending();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.Cve).HasColumnName("cve");
|
||||
entity.Property(e => e.AffectsKey).HasColumnName("affects_key");
|
||||
entity.Property(e => e.VersionRange).HasColumnName("version_range").HasColumnType("jsonb");
|
||||
entity.Property(e => e.Weakness).HasColumnName("weakness");
|
||||
entity.Property(e => e.MergeHash).HasColumnName("merge_hash");
|
||||
entity.Property(e => e.Status).HasColumnName("status").HasDefaultValue("active");
|
||||
entity.Property(e => e.Severity).HasColumnName("severity");
|
||||
entity.Property(e => e.EpssScore).HasColumnName("epss_score").HasColumnType("numeric(5,4)");
|
||||
entity.Property(e => e.ExploitKnown).HasColumnName("exploit_known").HasDefaultValue(false);
|
||||
entity.Property(e => e.Title).HasColumnName("title");
|
||||
entity.Property(e => e.Summary).HasColumnName("summary");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.advisory_source_edge
|
||||
// ================================================================
|
||||
modelBuilder.Entity<AdvisorySourceEdgeEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("advisory_source_edge_pkey");
|
||||
entity.ToTable("advisory_source_edge", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.CanonicalId, "idx_source_edge_canonical");
|
||||
entity.HasIndex(e => e.SourceId, "idx_source_edge_source");
|
||||
entity.HasIndex(e => e.SourceAdvisoryId, "idx_source_edge_advisory_id");
|
||||
entity.HasIndex(e => new { e.CanonicalId, e.SourceId, e.SourceDocHash }, "uq_advisory_source_edge_unique").IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.CanonicalId).HasColumnName("canonical_id");
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.SourceAdvisoryId).HasColumnName("source_advisory_id");
|
||||
entity.Property(e => e.SourceDocHash).HasColumnName("source_doc_hash");
|
||||
entity.Property(e => e.VendorStatus).HasColumnName("vendor_status");
|
||||
entity.Property(e => e.PrecedenceRank).HasColumnName("precedence_rank").HasDefaultValue(100);
|
||||
entity.Property(e => e.DsseEnvelope).HasColumnName("dsse_envelope").HasColumnType("jsonb");
|
||||
entity.Property(e => e.RawPayload).HasColumnName("raw_payload").HasColumnType("jsonb");
|
||||
entity.Property(e => e.FetchedAt).HasColumnName("fetched_at").HasDefaultValueSql("now()");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.provenance_scope
|
||||
// ================================================================
|
||||
modelBuilder.Entity<ProvenanceScopeEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("provenance_scope_pkey");
|
||||
entity.ToTable("provenance_scope", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.CanonicalId, "idx_provenance_scope_canonical");
|
||||
entity.HasIndex(e => e.DistroRelease, "idx_provenance_scope_distro");
|
||||
entity.HasIndex(e => new { e.CanonicalId, e.DistroRelease }, "uq_provenance_scope_canonical_distro").IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.CanonicalId).HasColumnName("canonical_id");
|
||||
entity.Property(e => e.DistroRelease).HasColumnName("distro_release");
|
||||
entity.Property(e => e.BackportSemver).HasColumnName("backport_semver");
|
||||
entity.Property(e => e.PatchId).HasColumnName("patch_id");
|
||||
entity.Property(e => e.PatchOrigin).HasColumnName("patch_origin");
|
||||
entity.Property(e => e.EvidenceRef).HasColumnName("evidence_ref");
|
||||
entity.Property(e => e.Confidence).HasColumnName("confidence").HasColumnType("numeric(3,2)").HasDefaultValue(0.5m);
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// concelier.source_documents
|
||||
// ================================================================
|
||||
modelBuilder.Entity<DocumentRecordEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.SourceName, e.Uri }).HasName("pk_source_documents");
|
||||
entity.ToTable("source_documents", concelierSchema);
|
||||
|
||||
entity.HasIndex(e => e.SourceId, "idx_source_documents_source_id");
|
||||
entity.HasIndex(e => e.Status, "idx_source_documents_status");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.SourceName).HasColumnName("source_name");
|
||||
entity.Property(e => e.Uri).HasColumnName("uri");
|
||||
entity.Property(e => e.Sha256).HasColumnName("sha256");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.ContentType).HasColumnName("content_type");
|
||||
entity.Property(e => e.HeadersJson).HasColumnName("headers_json").HasColumnType("jsonb");
|
||||
entity.Property(e => e.MetadataJson).HasColumnName("metadata_json").HasColumnType("jsonb");
|
||||
entity.Property(e => e.Etag).HasColumnName("etag");
|
||||
entity.Property(e => e.LastModified).HasColumnName("last_modified");
|
||||
entity.Property(e => e.Payload).HasColumnName("payload");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.interest_score
|
||||
// ================================================================
|
||||
modelBuilder.Entity<InterestScoreEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("interest_score_pkey");
|
||||
entity.ToTable("interest_score", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.CanonicalId, "uq_interest_score_canonical").IsUnique();
|
||||
entity.HasIndex(e => e.Score, "idx_interest_score_score").IsDescending();
|
||||
entity.HasIndex(e => e.ComputedAt, "idx_interest_score_computed").IsDescending();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.CanonicalId).HasColumnName("canonical_id");
|
||||
entity.Property(e => e.Score).HasColumnName("score").HasColumnType("numeric(3,2)");
|
||||
entity.Property(e => e.Reasons).HasColumnName("reasons").HasColumnType("jsonb").HasDefaultValueSql("'[]'::jsonb");
|
||||
entity.Property(e => e.LastSeenInBuild).HasColumnName("last_seen_in_build");
|
||||
entity.Property(e => e.ComputedAt).HasColumnName("computed_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// concelier.dtos
|
||||
// ================================================================
|
||||
modelBuilder.Entity<DtoRecordEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.DocumentId).HasName("pk_concelier_dtos");
|
||||
entity.ToTable("dtos", concelierSchema);
|
||||
|
||||
entity.HasIndex(e => new { e.SourceName, e.CreatedAt }, "idx_concelier_dtos_source")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.DocumentId).HasColumnName("document_id");
|
||||
entity.Property(e => e.SourceName).HasColumnName("source_name");
|
||||
entity.Property(e => e.Format).HasColumnName("format");
|
||||
entity.Property(e => e.PayloadJson).HasColumnName("payload_json").HasColumnType("jsonb");
|
||||
entity.Property(e => e.SchemaVersion).HasColumnName("schema_version").HasDefaultValue("");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
entity.Property(e => e.ValidatedAt).HasColumnName("validated_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// concelier.export_states
|
||||
// ================================================================
|
||||
modelBuilder.Entity<ExportStateEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("pk_concelier_export_states");
|
||||
entity.ToTable("export_states", concelierSchema);
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.ExportCursor).HasColumnName("export_cursor");
|
||||
entity.Property(e => e.LastFullDigest).HasColumnName("last_full_digest");
|
||||
entity.Property(e => e.LastDeltaDigest).HasColumnName("last_delta_digest");
|
||||
entity.Property(e => e.BaseExportId).HasColumnName("base_export_id");
|
||||
entity.Property(e => e.BaseDigest).HasColumnName("base_digest");
|
||||
entity.Property(e => e.TargetRepository).HasColumnName("target_repository");
|
||||
entity.Property(e => e.Files).HasColumnName("files").HasColumnType("jsonb");
|
||||
entity.Property(e => e.ExporterVersion).HasColumnName("exporter_version");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// concelier.psirt_flags
|
||||
// ================================================================
|
||||
modelBuilder.Entity<PsirtFlagEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.AdvisoryId, e.Vendor }).HasName("pk_concelier_psirt_flags");
|
||||
entity.ToTable("psirt_flags", concelierSchema);
|
||||
|
||||
entity.HasIndex(e => new { e.SourceName, e.RecordedAt }, "idx_concelier_psirt_source")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
|
||||
entity.Property(e => e.Vendor).HasColumnName("vendor");
|
||||
entity.Property(e => e.SourceName).HasColumnName("source_name");
|
||||
entity.Property(e => e.ExternalId).HasColumnName("external_id");
|
||||
entity.Property(e => e.RecordedAt).HasColumnName("recorded_at");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// concelier.jp_flags
|
||||
// ================================================================
|
||||
modelBuilder.Entity<JpFlagEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.AdvisoryKey).HasName("pk_concelier_jp_flags");
|
||||
entity.ToTable("jp_flags", concelierSchema);
|
||||
|
||||
entity.Property(e => e.AdvisoryKey).HasColumnName("advisory_key");
|
||||
entity.Property(e => e.SourceName).HasColumnName("source_name");
|
||||
entity.Property(e => e.Category).HasColumnName("category");
|
||||
entity.Property(e => e.VendorStatus).HasColumnName("vendor_status");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// concelier.change_history
|
||||
// ================================================================
|
||||
modelBuilder.Entity<ChangeHistoryEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("pk_concelier_change_history");
|
||||
entity.ToTable("change_history", concelierSchema);
|
||||
|
||||
entity.HasIndex(e => new { e.AdvisoryKey, e.CreatedAt }, "idx_concelier_change_history_advisory")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.SourceName).HasColumnName("source_name");
|
||||
entity.Property(e => e.AdvisoryKey).HasColumnName("advisory_key");
|
||||
entity.Property(e => e.DocumentId).HasColumnName("document_id");
|
||||
entity.Property(e => e.DocumentHash).HasColumnName("document_hash");
|
||||
entity.Property(e => e.SnapshotHash).HasColumnName("snapshot_hash");
|
||||
entity.Property(e => e.PreviousSnapshotHash).HasColumnName("previous_snapshot_hash");
|
||||
entity.Property(e => e.Snapshot).HasColumnName("snapshot").HasColumnType("jsonb");
|
||||
entity.Property(e => e.PreviousSnapshot).HasColumnName("previous_snapshot").HasColumnType("jsonb");
|
||||
entity.Property(e => e.Changes).HasColumnName("changes").HasColumnType("jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// concelier.sbom_documents
|
||||
// ================================================================
|
||||
modelBuilder.Entity<SbomDocumentEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("sbom_documents_pkey");
|
||||
entity.ToTable("sbom_documents", concelierSchema);
|
||||
|
||||
entity.HasIndex(e => e.SerialNumber, "uq_concelier_sbom_serial").IsUnique();
|
||||
entity.HasIndex(e => e.ArtifactDigest, "uq_concelier_sbom_artifact").IsUnique();
|
||||
entity.HasIndex(e => e.UpdatedAt, "idx_concelier_sbom_updated").IsDescending();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.SerialNumber).HasColumnName("serial_number");
|
||||
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
|
||||
entity.Property(e => e.Format).HasColumnName("format");
|
||||
entity.Property(e => e.SpecVersion).HasColumnName("spec_version");
|
||||
entity.Property(e => e.ComponentCount).HasColumnName("component_count").HasDefaultValue(0);
|
||||
entity.Property(e => e.ServiceCount).HasColumnName("service_count").HasDefaultValue(0);
|
||||
entity.Property(e => e.VulnerabilityCount).HasColumnName("vulnerability_count").HasDefaultValue(0);
|
||||
entity.Property(e => e.HasCrypto).HasColumnName("has_crypto").HasDefaultValue(false);
|
||||
entity.Property(e => e.HasServices).HasColumnName("has_services").HasDefaultValue(false);
|
||||
entity.Property(e => e.HasVulnerabilities).HasColumnName("has_vulnerabilities").HasDefaultValue(false);
|
||||
entity.Property(e => e.LicenseIds).HasColumnName("license_ids");
|
||||
entity.Property(e => e.LicenseExpressions).HasColumnName("license_expressions");
|
||||
entity.Property(e => e.SbomJson).HasColumnName("sbom_json").HasColumnType("jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for dotnet ef CLI tooling.
|
||||
/// Does NOT use compiled models (reflection-based discovery for scaffold/optimize).
|
||||
/// </summary>
|
||||
public sealed class ConcelierDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ConcelierDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=vuln,concelier,public";
|
||||
private const string ConnectionStringEnvironmentVariable =
|
||||
"STELLAOPS_CONCELIER_EF_CONNECTION";
|
||||
|
||||
public ConcelierDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<ConcelierDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new ConcelierDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Concelier.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating ConcelierDbContext instances.
|
||||
/// Applies the compiled model for default schema (performance path).
|
||||
/// Falls back to reflection-based model for non-default schemas (integration tests).
|
||||
/// </summary>
|
||||
internal static class ConcelierDbContextFactory
|
||||
{
|
||||
public static ConcelierDbContext Create(
|
||||
NpgsqlConnection connection,
|
||||
int commandTimeoutSeconds,
|
||||
string? schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? ConcelierDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<ConcelierDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
// Use static compiled model ONLY for default schema path.
|
||||
// Guard: only apply if the compiled model has entity types registered
|
||||
// (empty stub models bypass OnModelCreating and cause DbSet errors).
|
||||
if (string.Equals(normalizedSchema, ConcelierDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
var compiledModel = ConcelierDbContextModel.Instance;
|
||||
if (compiledModel.GetEntityTypes().Any())
|
||||
{
|
||||
optionsBuilder.UseModel(compiledModel);
|
||||
}
|
||||
}
|
||||
|
||||
return new ConcelierDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity for concelier.change_history table.
|
||||
/// Stores advisory change tracking with before/after snapshots and field-level diffs.
|
||||
/// </summary>
|
||||
public sealed class ChangeHistoryEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
public string AdvisoryKey { get; set; } = string.Empty;
|
||||
public Guid DocumentId { get; set; }
|
||||
public string DocumentHash { get; set; } = string.Empty;
|
||||
public string SnapshotHash { get; set; } = string.Empty;
|
||||
public string? PreviousSnapshotHash { get; set; }
|
||||
public string Snapshot { get; set; } = string.Empty;
|
||||
public string? PreviousSnapshot { get; set; }
|
||||
public string Changes { get; set; } = "[]";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity for concelier.dtos table.
|
||||
/// Stores parsed DTO documents keyed by document_id.
|
||||
/// </summary>
|
||||
public sealed class DtoRecordEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid DocumentId { get; set; }
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
public string Format { get; set; } = string.Empty;
|
||||
public string PayloadJson { get; set; } = string.Empty;
|
||||
public string SchemaVersion { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime ValidatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity for concelier.export_states table.
|
||||
/// Tracks state of feed export processes.
|
||||
/// </summary>
|
||||
public sealed class ExportStateEntity
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string ExportCursor { get; set; } = string.Empty;
|
||||
public string? LastFullDigest { get; set; }
|
||||
public string? LastDeltaDigest { get; set; }
|
||||
public string? BaseExportId { get; set; }
|
||||
public string? BaseDigest { get; set; }
|
||||
public string? TargetRepository { get; set; }
|
||||
public string Files { get; set; } = "[]";
|
||||
public string ExporterVersion { get; set; } = string.Empty;
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity for vuln.interest_score table.
|
||||
/// Stores computed interest scores for advisory canonicals.
|
||||
/// </summary>
|
||||
public sealed class InterestScoreEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid CanonicalId { get; set; }
|
||||
public decimal Score { get; set; }
|
||||
public string Reasons { get; set; } = "[]";
|
||||
public Guid? LastSeenInBuild { get; set; }
|
||||
public DateTimeOffset ComputedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity for concelier.jp_flags table.
|
||||
/// Tracks Japan CERT vendor status flags per advisory.
|
||||
/// </summary>
|
||||
public sealed class JpFlagEntity
|
||||
{
|
||||
public string AdvisoryKey { get; set; } = string.Empty;
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public string? VendorStatus { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity for concelier.psirt_flags table.
|
||||
/// Tracks PSIRT (Product Security Incident Response Team) flags per advisory/vendor.
|
||||
/// </summary>
|
||||
public sealed class PsirtFlagEntity
|
||||
{
|
||||
public string AdvisoryId { get; set; } = string.Empty;
|
||||
public string Vendor { get; set; } = string.Empty;
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
public string? ExternalId { get; set; }
|
||||
public DateTimeOffset RecordedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity for concelier.sbom_documents table.
|
||||
/// Stores enriched SBOM documents with license and component metadata.
|
||||
/// </summary>
|
||||
public sealed class SbomDocumentEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string SerialNumber { get; set; } = string.Empty;
|
||||
public string? ArtifactDigest { get; set; }
|
||||
public string Format { get; set; } = string.Empty;
|
||||
public string SpecVersion { get; set; } = string.Empty;
|
||||
public int ComponentCount { get; set; }
|
||||
public int ServiceCount { get; set; }
|
||||
public int VulnerabilityCount { get; set; }
|
||||
public bool HasCrypto { get; set; }
|
||||
public bool HasServices { get; set; }
|
||||
public bool HasVulnerabilities { get; set; }
|
||||
public string[] LicenseIds { get; set; } = [];
|
||||
public string[] LicenseExpressions { get; set; } = [];
|
||||
public string SbomJson { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -1,72 +1,79 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for advisory snapshots.
|
||||
/// </summary>
|
||||
public sealed class AdvisorySnapshotRepository : RepositoryBase<ConcelierDataSource>, IAdvisorySnapshotRepository
|
||||
public sealed class AdvisorySnapshotRepository : IAdvisorySnapshotRepository
|
||||
{
|
||||
private const string SystemTenantId = "_system";
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly ILogger<AdvisorySnapshotRepository> _logger;
|
||||
|
||||
public AdvisorySnapshotRepository(ConcelierDataSource dataSource, ILogger<AdvisorySnapshotRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<AdvisorySnapshotEntity> InsertAsync(AdvisorySnapshotEntity snapshot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// ON CONFLICT upsert with RETURNING requires raw SQL.
|
||||
const string sql = """
|
||||
INSERT INTO vuln.advisory_snapshots
|
||||
(id, feed_snapshot_id, advisory_key, content_hash)
|
||||
VALUES
|
||||
(@id, @feed_snapshot_id, @advisory_key, @content_hash)
|
||||
({0}, {1}, {2}, {3})
|
||||
ON CONFLICT (feed_snapshot_id, advisory_key) DO UPDATE SET
|
||||
content_hash = EXCLUDED.content_hash
|
||||
RETURNING id, feed_snapshot_id, advisory_key, content_hash, created_at
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var rows = await context.Database.SqlQueryRaw<AdvisorySnapshotRawResult>(
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", snapshot.Id);
|
||||
AddParameter(cmd, "feed_snapshot_id", snapshot.FeedSnapshotId);
|
||||
AddParameter(cmd, "advisory_key", snapshot.AdvisoryKey);
|
||||
AddParameter(cmd, "content_hash", snapshot.ContentHash);
|
||||
},
|
||||
MapSnapshot!,
|
||||
cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Insert returned null");
|
||||
snapshot.Id,
|
||||
snapshot.FeedSnapshotId,
|
||||
snapshot.AdvisoryKey,
|
||||
snapshot.ContentHash)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var row = rows.SingleOrDefault() ?? throw new InvalidOperationException("Insert returned null");
|
||||
|
||||
return new AdvisorySnapshotEntity
|
||||
{
|
||||
Id = row.id,
|
||||
FeedSnapshotId = row.feed_snapshot_id,
|
||||
AdvisoryKey = row.advisory_key,
|
||||
ContentHash = row.content_hash,
|
||||
CreatedAt = row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisorySnapshotEntity>> GetByFeedSnapshotAsync(Guid feedSnapshotId, CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<AdvisorySnapshotEntity>> GetByFeedSnapshotAsync(Guid feedSnapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, feed_snapshot_id, advisory_key, content_hash, created_at
|
||||
FROM vuln.advisory_snapshots
|
||||
WHERE feed_snapshot_id = @feed_snapshot_id
|
||||
ORDER BY advisory_key
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "feed_snapshot_id", feedSnapshotId),
|
||||
MapSnapshot,
|
||||
cancellationToken);
|
||||
return await context.AdvisorySnapshots
|
||||
.AsNoTracking()
|
||||
.Where(s => s.FeedSnapshotId == feedSnapshotId)
|
||||
.OrderBy(s => s.AdvisoryKey)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static AdvisorySnapshotEntity MapSnapshot(NpgsqlDataReader reader) => new()
|
||||
private sealed class AdvisorySnapshotRawResult
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
FeedSnapshotId = reader.GetGuid(1),
|
||||
AdvisoryKey = reader.GetString(2),
|
||||
ContentHash = reader.GetString(3),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(4)
|
||||
};
|
||||
public Guid id { get; init; }
|
||||
public Guid feed_snapshot_id { get; init; }
|
||||
public string advisory_key { get; init; } = string.Empty;
|
||||
public string content_hash { get; init; } = string.Empty;
|
||||
public DateTimeOffset created_at { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
|
||||
@@ -17,97 +14,88 @@ public interface IDocumentRepository
|
||||
Task UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class DocumentRepository : RepositoryBase<ConcelierDataSource>, IDocumentRepository
|
||||
public sealed class DocumentRepository : IDocumentRepository
|
||||
{
|
||||
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly ILogger<DocumentRepository> _logger;
|
||||
|
||||
public DocumentRepository(ConcelierDataSource dataSource, ILogger<DocumentRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DocumentRecordEntity?> FindAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM concelier.source_documents
|
||||
WHERE id = @Id
|
||||
LIMIT 1;
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
await using var conn = await DataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var row = await conn.QuerySingleOrDefaultAsync(sql, new { Id = id });
|
||||
return row is null ? null : Map(row);
|
||||
return await context.SourceDocuments
|
||||
.AsNoTracking()
|
||||
.Where(d => d.Id == id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DocumentRecordEntity?> FindBySourceAndUriAsync(string sourceName, string uri, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM concelier.source_documents
|
||||
WHERE source_name = @SourceName AND uri = @Uri
|
||||
LIMIT 1;
|
||||
""";
|
||||
await using var conn = await DataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var row = await conn.QuerySingleOrDefaultAsync(sql, new { SourceName = sourceName, Uri = uri });
|
||||
return row is null ? null : Map(row);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
return await context.SourceDocuments
|
||||
.AsNoTracking()
|
||||
.Where(d => d.SourceName == sourceName && d.Uri == uri)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DocumentRecordEntity> UpsertAsync(DocumentRecordEntity record, CancellationToken cancellationToken)
|
||||
{
|
||||
// ON CONFLICT upsert with RETURNING * and jsonb casts on a composite-key table requires raw SQL.
|
||||
const string sql = """
|
||||
INSERT INTO concelier.source_documents (
|
||||
id, source_id, source_name, uri, sha256, status, content_type,
|
||||
headers_json, metadata_json, etag, last_modified, payload, created_at, updated_at, expires_at)
|
||||
VALUES (
|
||||
@Id, @SourceId, @SourceName, @Uri, @Sha256, @Status, @ContentType,
|
||||
@HeadersJson::jsonb, @MetadataJson::jsonb, @Etag, @LastModified, @Payload, @CreatedAt, @UpdatedAt, @ExpiresAt)
|
||||
ON CONFLICT (source_name, uri) DO UPDATE SET
|
||||
sha256 = EXCLUDED.sha256,
|
||||
status = EXCLUDED.status,
|
||||
content_type = EXCLUDED.content_type,
|
||||
headers_json = EXCLUDED.headers_json,
|
||||
metadata_json = EXCLUDED.metadata_json,
|
||||
etag = EXCLUDED.etag,
|
||||
last_modified = EXCLUDED.last_modified,
|
||||
payload = EXCLUDED.payload,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
RETURNING *;
|
||||
""";
|
||||
await using var conn = await DataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var row = await conn.QuerySingleAsync(sql, new
|
||||
{
|
||||
INSERT INTO concelier.source_documents (
|
||||
id, source_id, source_name, uri, sha256, status, content_type,
|
||||
headers_json, metadata_json, etag, last_modified, payload, created_at, updated_at, expires_at)
|
||||
VALUES (
|
||||
{0}, {1}, {2}, {3}, {4}, {5}, {6},
|
||||
{7}::jsonb, {8}::jsonb, {9}, {10}, {11}, {12}, {13}, {14})
|
||||
ON CONFLICT (source_name, uri) DO UPDATE SET
|
||||
sha256 = EXCLUDED.sha256,
|
||||
status = EXCLUDED.status,
|
||||
content_type = EXCLUDED.content_type,
|
||||
headers_json = EXCLUDED.headers_json,
|
||||
metadata_json = EXCLUDED.metadata_json,
|
||||
etag = EXCLUDED.etag,
|
||||
last_modified = EXCLUDED.last_modified,
|
||||
payload = EXCLUDED.payload,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
RETURNING id, source_id, source_name, uri, sha256, status, content_type,
|
||||
headers_json::text, metadata_json::text, etag, last_modified, payload,
|
||||
created_at, updated_at, expires_at
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var rows = await context.Database.SqlQueryRaw<DocumentRawResult>(
|
||||
sql,
|
||||
record.Id,
|
||||
record.SourceId,
|
||||
record.SourceName,
|
||||
record.Uri,
|
||||
record.Sha256,
|
||||
record.Status,
|
||||
record.ContentType,
|
||||
record.HeadersJson,
|
||||
record.MetadataJson,
|
||||
record.Etag,
|
||||
record.LastModified,
|
||||
record.ContentType ?? (object)DBNull.Value,
|
||||
record.HeadersJson ?? (object)DBNull.Value,
|
||||
record.MetadataJson ?? (object)DBNull.Value,
|
||||
record.Etag ?? (object)DBNull.Value,
|
||||
record.LastModified ?? (object)DBNull.Value,
|
||||
record.Payload,
|
||||
record.CreatedAt,
|
||||
record.UpdatedAt,
|
||||
record.ExpiresAt
|
||||
});
|
||||
return Map(row);
|
||||
}
|
||||
record.ExpiresAt ?? (object)DBNull.Value)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public async Task UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE concelier.source_documents
|
||||
SET status = @Status, updated_at = NOW()
|
||||
WHERE id = @Id;
|
||||
""";
|
||||
await using var conn = await DataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await conn.ExecuteAsync(sql, new { Id = id, Status = status });
|
||||
}
|
||||
|
||||
private DocumentRecordEntity Map(dynamic row)
|
||||
{
|
||||
var row = rows.Single();
|
||||
return new DocumentRecordEntity(
|
||||
row.id,
|
||||
row.source_id,
|
||||
@@ -115,14 +103,44 @@ WHERE id = @Id;
|
||||
row.uri,
|
||||
row.sha256,
|
||||
row.status,
|
||||
(string?)row.content_type,
|
||||
(string?)row.headers_json,
|
||||
(string?)row.metadata_json,
|
||||
(string?)row.etag,
|
||||
(DateTimeOffset?)row.last_modified,
|
||||
(byte[])row.payload,
|
||||
DateTime.SpecifyKind(row.created_at, DateTimeKind.Utc),
|
||||
DateTime.SpecifyKind(row.updated_at, DateTimeKind.Utc),
|
||||
row.expires_at is null ? null : DateTime.SpecifyKind(row.expires_at, DateTimeKind.Utc));
|
||||
row.content_type,
|
||||
row.headers_json,
|
||||
row.metadata_json,
|
||||
row.etag,
|
||||
row.last_modified,
|
||||
row.payload,
|
||||
row.created_at,
|
||||
row.updated_at,
|
||||
row.expires_at);
|
||||
}
|
||||
|
||||
public async Task UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE concelier.source_documents SET status = {0}, updated_at = NOW() WHERE id = {1}",
|
||||
[status, id],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class DocumentRawResult
|
||||
{
|
||||
public Guid id { get; init; }
|
||||
public Guid source_id { get; init; }
|
||||
public string source_name { get; init; } = string.Empty;
|
||||
public string uri { get; init; } = string.Empty;
|
||||
public string sha256 { get; init; } = string.Empty;
|
||||
public string status { get; init; } = string.Empty;
|
||||
public string? content_type { get; init; }
|
||||
public string? headers_json { get; init; }
|
||||
public string? metadata_json { get; init; }
|
||||
public string? etag { get; init; }
|
||||
public DateTimeOffset? last_modified { get; init; }
|
||||
public byte[] payload { get; init; } = [];
|
||||
public DateTime created_at { get; init; }
|
||||
public DateTime updated_at { get; init; }
|
||||
public DateTime? expires_at { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,87 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for feed snapshots.
|
||||
/// </summary>
|
||||
public sealed class FeedSnapshotRepository : RepositoryBase<ConcelierDataSource>, IFeedSnapshotRepository
|
||||
public sealed class FeedSnapshotRepository : IFeedSnapshotRepository
|
||||
{
|
||||
private const string SystemTenantId = "_system";
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly ILogger<FeedSnapshotRepository> _logger;
|
||||
|
||||
public FeedSnapshotRepository(ConcelierDataSource dataSource, ILogger<FeedSnapshotRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<FeedSnapshotEntity> InsertAsync(FeedSnapshotEntity snapshot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// ON CONFLICT DO NOTHING with RETURNING requires raw SQL.
|
||||
const string sql = """
|
||||
INSERT INTO vuln.feed_snapshots
|
||||
(id, source_id, snapshot_id, advisory_count, checksum, metadata)
|
||||
VALUES
|
||||
(@id, @source_id, @snapshot_id, @advisory_count, @checksum, @metadata::jsonb)
|
||||
({0}, {1}, {2}, {3}, {4}, {5}::jsonb)
|
||||
ON CONFLICT (source_id, snapshot_id) DO NOTHING
|
||||
RETURNING id, source_id, snapshot_id, advisory_count, checksum, metadata::text, created_at
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var rows = await context.Database.SqlQueryRaw<FeedSnapshotRawResult>(
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", snapshot.Id);
|
||||
AddParameter(cmd, "source_id", snapshot.SourceId);
|
||||
AddParameter(cmd, "snapshot_id", snapshot.SnapshotId);
|
||||
AddParameter(cmd, "advisory_count", snapshot.AdvisoryCount);
|
||||
AddParameter(cmd, "checksum", snapshot.Checksum);
|
||||
AddJsonbParameter(cmd, "metadata", snapshot.Metadata);
|
||||
},
|
||||
MapSnapshot!,
|
||||
cancellationToken).ConfigureAwait(false) ?? snapshot;
|
||||
snapshot.Id,
|
||||
snapshot.SourceId,
|
||||
snapshot.SnapshotId,
|
||||
snapshot.AdvisoryCount,
|
||||
snapshot.Checksum ?? (object)DBNull.Value,
|
||||
snapshot.Metadata)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return snapshot; // ON CONFLICT DO NOTHING -> return original
|
||||
}
|
||||
|
||||
var row = rows.Single();
|
||||
return new FeedSnapshotEntity
|
||||
{
|
||||
Id = row.id,
|
||||
SourceId = row.source_id,
|
||||
SnapshotId = row.snapshot_id,
|
||||
AdvisoryCount = row.advisory_count,
|
||||
Checksum = row.checksum,
|
||||
Metadata = row.metadata ?? "{}",
|
||||
CreatedAt = row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
public Task<FeedSnapshotEntity?> GetBySourceAndIdAsync(Guid sourceId, string snapshotId, CancellationToken cancellationToken = default)
|
||||
public async Task<FeedSnapshotEntity?> GetBySourceAndIdAsync(Guid sourceId, string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, source_id, snapshot_id, advisory_count, checksum, metadata::text, created_at
|
||||
FROM vuln.feed_snapshots
|
||||
WHERE source_id = @source_id AND snapshot_id = @snapshot_id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "source_id", sourceId);
|
||||
AddParameter(cmd, "snapshot_id", snapshotId);
|
||||
},
|
||||
MapSnapshot,
|
||||
cancellationToken);
|
||||
return await context.FeedSnapshots
|
||||
.AsNoTracking()
|
||||
.Where(f => f.SourceId == sourceId && f.SnapshotId == snapshotId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static FeedSnapshotEntity MapSnapshot(NpgsqlDataReader reader) => new()
|
||||
private sealed class FeedSnapshotRawResult
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
SourceId = reader.GetGuid(1),
|
||||
SnapshotId = reader.GetString(2),
|
||||
AdvisoryCount = reader.GetInt32(3),
|
||||
Checksum = GetNullableString(reader, 4),
|
||||
Metadata = reader.GetString(5),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6)
|
||||
};
|
||||
public Guid id { get; init; }
|
||||
public Guid source_id { get; init; }
|
||||
public string snapshot_id { get; init; } = string.Empty;
|
||||
public int advisory_count { get; init; }
|
||||
public string? checksum { get; init; }
|
||||
public string? metadata { get; init; }
|
||||
public DateTimeOffset created_at { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,114 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for KEV flags.
|
||||
/// </summary>
|
||||
public sealed class KevFlagRepository : RepositoryBase<ConcelierDataSource>, IKevFlagRepository
|
||||
public sealed class KevFlagRepository : IKevFlagRepository
|
||||
{
|
||||
private const string SystemTenantId = "_system";
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly ILogger<KevFlagRepository> _logger;
|
||||
|
||||
public KevFlagRepository(ConcelierDataSource dataSource, ILogger<KevFlagRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task ReplaceAsync(Guid advisoryId, IEnumerable<KevFlagEntity> flags, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
const string deleteSql = "DELETE FROM vuln.kev_flags WHERE advisory_id = @advisory_id";
|
||||
await using (var deleteCmd = CreateCommand(deleteSql, connection))
|
||||
{
|
||||
deleteCmd.Transaction = transaction;
|
||||
AddParameter(deleteCmd, "advisory_id", advisoryId);
|
||||
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
const string insertSql = """
|
||||
INSERT INTO vuln.kev_flags
|
||||
(id, advisory_id, cve_id, vendor_project, product, vulnerability_name,
|
||||
date_added, due_date, known_ransomware_use, notes)
|
||||
VALUES
|
||||
(@id, @advisory_id, @cve_id, @vendor_project, @product, @vulnerability_name,
|
||||
@date_added, @due_date, @known_ransomware_use, @notes)
|
||||
""";
|
||||
// Delete existing flags for this advisory
|
||||
await context.KevFlags
|
||||
.Where(k => k.AdvisoryId == advisoryId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
// Insert new flags (caller must have set AdvisoryId on each flag)
|
||||
foreach (var flag in flags)
|
||||
{
|
||||
await using var insertCmd = CreateCommand(insertSql, connection);
|
||||
insertCmd.Transaction = transaction;
|
||||
AddParameter(insertCmd, "id", flag.Id);
|
||||
AddParameter(insertCmd, "advisory_id", advisoryId);
|
||||
AddParameter(insertCmd, "cve_id", flag.CveId);
|
||||
AddParameter(insertCmd, "vendor_project", flag.VendorProject);
|
||||
AddParameter(insertCmd, "product", flag.Product);
|
||||
AddParameter(insertCmd, "vulnerability_name", flag.VulnerabilityName);
|
||||
AddParameter(insertCmd, "date_added", flag.DateAdded);
|
||||
AddParameter(insertCmd, "due_date", flag.DueDate);
|
||||
AddParameter(insertCmd, "known_ransomware_use", flag.KnownRansomwareUse);
|
||||
AddParameter(insertCmd, "notes", flag.Notes);
|
||||
|
||||
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
context.KevFlags.Add(flag);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<KevFlagEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<KevFlagEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, advisory_id, cve_id, vendor_project, product, vulnerability_name,
|
||||
date_added, due_date, known_ransomware_use, notes, created_at
|
||||
FROM vuln.kev_flags
|
||||
WHERE advisory_id = @advisory_id
|
||||
ORDER BY date_added DESC, cve_id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "advisory_id", advisoryId),
|
||||
MapKev,
|
||||
cancellationToken);
|
||||
return await context.KevFlags
|
||||
.AsNoTracking()
|
||||
.Where(k => k.AdvisoryId == advisoryId)
|
||||
.OrderByDescending(k => k.DateAdded)
|
||||
.ThenBy(k => k.CveId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<KevFlagEntity>> GetByCveAsync(string cveId, CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<KevFlagEntity>> GetByCveAsync(string cveId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, advisory_id, cve_id, vendor_project, product, vulnerability_name,
|
||||
date_added, due_date, known_ransomware_use, notes, created_at
|
||||
FROM vuln.kev_flags
|
||||
WHERE cve_id = @cve_id
|
||||
ORDER BY date_added DESC, advisory_id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "cve_id", cveId),
|
||||
MapKev,
|
||||
cancellationToken);
|
||||
return await context.KevFlags
|
||||
.AsNoTracking()
|
||||
.Where(k => k.CveId == cveId)
|
||||
.OrderByDescending(k => k.DateAdded)
|
||||
.ThenBy(k => k.AdvisoryId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static KevFlagEntity MapKev(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
AdvisoryId = reader.GetGuid(1),
|
||||
CveId = reader.GetString(2),
|
||||
VendorProject = GetNullableString(reader, 3),
|
||||
Product = GetNullableString(reader, 4),
|
||||
VulnerabilityName = GetNullableString(reader, 5),
|
||||
DateAdded = DateOnly.FromDateTime(reader.GetDateTime(6)),
|
||||
DueDate = reader.IsDBNull(7) ? null : DateOnly.FromDateTime(reader.GetDateTime(7)),
|
||||
KnownRansomwareUse = reader.GetBoolean(8),
|
||||
Notes = GetNullableString(reader, 9),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +1,86 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for merge event audit records.
|
||||
/// </summary>
|
||||
public sealed class MergeEventRepository : RepositoryBase<ConcelierDataSource>, IMergeEventRepository
|
||||
public sealed class MergeEventRepository : IMergeEventRepository
|
||||
{
|
||||
private const string SystemTenantId = "_system";
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly ILogger<MergeEventRepository> _logger;
|
||||
|
||||
public MergeEventRepository(ConcelierDataSource dataSource, ILogger<MergeEventRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<MergeEventEntity> InsertAsync(MergeEventEntity evt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Insert with RETURNING and jsonb casts requires raw SQL.
|
||||
// Partitioned table (merge_events) - inserts must go through SQL, not EF Add.
|
||||
const string sql = """
|
||||
INSERT INTO vuln.merge_events
|
||||
(advisory_id, source_id, event_type, old_value, new_value)
|
||||
VALUES
|
||||
(@advisory_id, @source_id, @event_type, @old_value::jsonb, @new_value::jsonb)
|
||||
({0}, {1}, {2}, {3}::jsonb, {4}::jsonb)
|
||||
RETURNING id, advisory_id, source_id, event_type, old_value::text, new_value::text, created_at
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var rows = await context.Database.SqlQueryRaw<MergeEventRawResult>(
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "advisory_id", evt.AdvisoryId);
|
||||
AddParameter(cmd, "source_id", evt.SourceId);
|
||||
AddParameter(cmd, "event_type", evt.EventType);
|
||||
AddJsonbParameter(cmd, "old_value", evt.OldValue);
|
||||
AddJsonbParameter(cmd, "new_value", evt.NewValue);
|
||||
},
|
||||
MapEvent!,
|
||||
cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Insert returned null");
|
||||
evt.AdvisoryId,
|
||||
evt.SourceId ?? (object)DBNull.Value,
|
||||
evt.EventType,
|
||||
evt.OldValue ?? (object)DBNull.Value,
|
||||
evt.NewValue ?? (object)DBNull.Value)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var row = rows.SingleOrDefault() ?? throw new InvalidOperationException("Insert returned null");
|
||||
|
||||
return new MergeEventEntity
|
||||
{
|
||||
Id = row.id,
|
||||
AdvisoryId = row.advisory_id,
|
||||
SourceId = row.source_id,
|
||||
EventType = row.event_type,
|
||||
OldValue = row.old_value,
|
||||
NewValue = row.new_value,
|
||||
CreatedAt = row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MergeEventEntity>> GetByAdvisoryAsync(Guid advisoryId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<MergeEventEntity>> GetByAdvisoryAsync(Guid advisoryId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, advisory_id, source_id, event_type, old_value::text, new_value::text, created_at
|
||||
FROM vuln.merge_events
|
||||
WHERE advisory_id = @advisory_id
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "advisory_id", advisoryId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapEvent,
|
||||
cancellationToken);
|
||||
return await context.MergeEvents
|
||||
.AsNoTracking()
|
||||
.Where(e => e.AdvisoryId == advisoryId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ThenByDescending(e => e.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static MergeEventEntity MapEvent(NpgsqlDataReader reader) => new()
|
||||
private sealed class MergeEventRawResult
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
AdvisoryId = reader.GetGuid(1),
|
||||
SourceId = GetNullableGuid(reader, 2),
|
||||
EventType = reader.GetString(3),
|
||||
OldValue = GetNullableString(reader, 4),
|
||||
NewValue = GetNullableString(reader, 5),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6)
|
||||
};
|
||||
public long id { get; init; }
|
||||
public Guid advisory_id { get; init; }
|
||||
public Guid? source_id { get; init; }
|
||||
public string event_type { get; init; } = string.Empty;
|
||||
public string? old_value { get; init; }
|
||||
public string? new_value { get; init; }
|
||||
public DateTimeOffset created_at { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.ChangeHistory;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -20,80 +22,66 @@ internal sealed class PostgresChangeHistoryStore : IChangeHistoryStore
|
||||
|
||||
public async Task AddAsync(ChangeHistoryRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
// ON CONFLICT (id) DO NOTHING requires raw SQL.
|
||||
const string sql = """
|
||||
INSERT INTO concelier.change_history
|
||||
(id, source_name, advisory_key, document_id, document_hash, snapshot_hash, previous_snapshot_hash, snapshot, previous_snapshot, changes, created_at)
|
||||
VALUES (@Id, @SourceName, @AdvisoryKey, @DocumentId, @DocumentHash, @SnapshotHash, @PreviousSnapshotHash, @Snapshot::jsonb, @PreviousSnapshot::jsonb, @Changes::jsonb, @CreatedAt)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}::jsonb, {8}::jsonb, {9}::jsonb, {10})
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""";
|
||||
|
||||
var changesJson = JsonSerializer.Serialize(record.Changes, _jsonOptions);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
record.Id,
|
||||
record.SourceName,
|
||||
record.AdvisoryKey,
|
||||
record.DocumentId,
|
||||
record.DocumentHash,
|
||||
record.SnapshotHash,
|
||||
record.PreviousSnapshotHash,
|
||||
Snapshot = record.Snapshot,
|
||||
PreviousSnapshot = record.PreviousSnapshot,
|
||||
Changes = JsonSerializer.Serialize(record.Changes, _jsonOptions),
|
||||
record.CreatedAt
|
||||
}, cancellationToken: cancellationToken));
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
sql,
|
||||
[
|
||||
record.Id,
|
||||
record.SourceName,
|
||||
record.AdvisoryKey,
|
||||
record.DocumentId,
|
||||
record.DocumentHash,
|
||||
record.SnapshotHash,
|
||||
record.PreviousSnapshotHash ?? (object)DBNull.Value,
|
||||
record.Snapshot,
|
||||
record.PreviousSnapshot ?? (object)DBNull.Value,
|
||||
changesJson,
|
||||
record.CreatedAt
|
||||
],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ChangeHistoryRecord>> GetRecentAsync(string sourceName, string advisoryKey, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, source_name, advisory_key, document_id, document_hash, snapshot_hash, previous_snapshot_hash, snapshot, previous_snapshot, changes, created_at
|
||||
FROM concelier.change_history
|
||||
WHERE source_name = @SourceName AND advisory_key = @AdvisoryKey
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @Limit;
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var rows = await connection.QueryAsync<ChangeHistoryRow>(new CommandDefinition(sql, new
|
||||
{
|
||||
SourceName = sourceName,
|
||||
AdvisoryKey = advisoryKey,
|
||||
Limit = limit
|
||||
}, cancellationToken: cancellationToken));
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
return rows.Select(ToRecord).ToArray();
|
||||
var entities = await context.ChangeHistory
|
||||
.AsNoTracking()
|
||||
.Where(c => c.SourceName == sourceName && c.AdvisoryKey == advisoryKey)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return entities.Select(ToRecord).ToArray();
|
||||
}
|
||||
|
||||
private ChangeHistoryRecord ToRecord(ChangeHistoryRow row)
|
||||
private ChangeHistoryRecord ToRecord(ChangeHistoryEntity entity)
|
||||
{
|
||||
var changes = JsonSerializer.Deserialize<IReadOnlyList<ChangeHistoryFieldChange>>(row.Changes, _jsonOptions) ?? Array.Empty<ChangeHistoryFieldChange>();
|
||||
var changes = JsonSerializer.Deserialize<IReadOnlyList<ChangeHistoryFieldChange>>(entity.Changes, _jsonOptions) ?? Array.Empty<ChangeHistoryFieldChange>();
|
||||
return new ChangeHistoryRecord(
|
||||
row.Id,
|
||||
row.SourceName,
|
||||
row.AdvisoryKey,
|
||||
row.DocumentId,
|
||||
row.DocumentHash,
|
||||
row.SnapshotHash,
|
||||
row.PreviousSnapshotHash ?? string.Empty,
|
||||
row.Snapshot,
|
||||
row.PreviousSnapshot ?? string.Empty,
|
||||
entity.Id,
|
||||
entity.SourceName,
|
||||
entity.AdvisoryKey,
|
||||
entity.DocumentId,
|
||||
entity.DocumentHash,
|
||||
entity.SnapshotHash,
|
||||
entity.PreviousSnapshotHash ?? string.Empty,
|
||||
entity.Snapshot,
|
||||
entity.PreviousSnapshot ?? string.Empty,
|
||||
changes,
|
||||
row.CreatedAt);
|
||||
}
|
||||
|
||||
private sealed class ChangeHistoryRow
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string SourceName { get; init; } = string.Empty;
|
||||
public string AdvisoryKey { get; init; } = string.Empty;
|
||||
public Guid DocumentId { get; init; }
|
||||
public string DocumentHash { get; init; } = string.Empty;
|
||||
public string SnapshotHash { get; init; } = string.Empty;
|
||||
public string? PreviousSnapshotHash { get; init; }
|
||||
public string Snapshot { get; init; } = string.Empty;
|
||||
public string? PreviousSnapshot { get; init; }
|
||||
public string Changes { get; init; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
entity.CreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
|
||||
using Contracts = StellaOps.Concelier.Storage.Contracts;
|
||||
using Dapper;
|
||||
using StellaOps.Concelier.Persistence.Postgres;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
@@ -11,10 +12,6 @@ namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
|
||||
{
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public PostgresDtoStore(ConcelierDataSource dataSource)
|
||||
{
|
||||
@@ -23,9 +20,10 @@ internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
|
||||
|
||||
public async Task<DtoRecord> UpsertAsync(DtoRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
// ON CONFLICT upsert with RETURNING requires raw SQL.
|
||||
const string sql = """
|
||||
INSERT INTO concelier.dtos (id, document_id, source_name, format, payload_json, schema_version, created_at, validated_at)
|
||||
VALUES (@Id, @DocumentId, @SourceName, @Format, @PayloadJson::jsonb, @SchemaVersion, @CreatedAt, @ValidatedAt)
|
||||
VALUES ({0}, {1}, {2}, {3}, {4}::jsonb, {5}, {6}, {7})
|
||||
ON CONFLICT (document_id) DO UPDATE
|
||||
SET payload_json = EXCLUDED.payload_json,
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
@@ -33,92 +31,87 @@ internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
|
||||
format = EXCLUDED.format,
|
||||
validated_at = EXCLUDED.validated_at
|
||||
RETURNING
|
||||
id AS "Id",
|
||||
document_id AS "DocumentId",
|
||||
source_name AS "SourceName",
|
||||
format AS "Format",
|
||||
payload_json::text AS "PayloadJson",
|
||||
schema_version AS "SchemaVersion",
|
||||
created_at AS "CreatedAt",
|
||||
validated_at AS "ValidatedAt";
|
||||
id, document_id, source_name, format, payload_json::text, schema_version, created_at, validated_at
|
||||
""";
|
||||
|
||||
var payloadJson = record.Payload.ToJson();
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var row = await connection.QuerySingleAsync<DtoRow>(new CommandDefinition(sql, new
|
||||
{
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var rows = await context.Database.SqlQueryRaw<DtoRawResult>(
|
||||
sql,
|
||||
record.Id,
|
||||
record.DocumentId,
|
||||
record.SourceName,
|
||||
record.Format,
|
||||
PayloadJson = payloadJson,
|
||||
payloadJson,
|
||||
record.SchemaVersion,
|
||||
record.CreatedAt,
|
||||
record.ValidatedAt
|
||||
}, cancellationToken: cancellationToken));
|
||||
record.ValidatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var row = rows.Single();
|
||||
return ToRecord(row);
|
||||
}
|
||||
|
||||
public async Task<DtoRecord?> FindByDocumentIdAsync(Guid documentId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
id AS "Id",
|
||||
document_id AS "DocumentId",
|
||||
source_name AS "SourceName",
|
||||
format AS "Format",
|
||||
payload_json::text AS "PayloadJson",
|
||||
schema_version AS "SchemaVersion",
|
||||
created_at AS "CreatedAt",
|
||||
validated_at AS "ValidatedAt"
|
||||
FROM concelier.dtos
|
||||
WHERE document_id = @DocumentId
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<DtoRow>(new CommandDefinition(sql, new { DocumentId = documentId }, cancellationToken: cancellationToken));
|
||||
return row is null ? null : ToRecord(row);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var entity = await context.Dtos
|
||||
.AsNoTracking()
|
||||
.Where(d => d.DocumentId == documentId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return entity is null ? null : ToRecord(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DtoRecord>> GetBySourceAsync(string sourceName, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
id AS "Id",
|
||||
document_id AS "DocumentId",
|
||||
source_name AS "SourceName",
|
||||
format AS "Format",
|
||||
payload_json::text AS "PayloadJson",
|
||||
schema_version AS "SchemaVersion",
|
||||
created_at AS "CreatedAt",
|
||||
validated_at AS "ValidatedAt"
|
||||
FROM concelier.dtos
|
||||
WHERE source_name = @SourceName
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @Limit;
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var rows = await connection.QueryAsync<DtoRow>(new CommandDefinition(sql, new { SourceName = sourceName, Limit = limit }, cancellationToken: cancellationToken));
|
||||
return rows.Select(ToRecord).ToArray();
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var entities = await context.Dtos
|
||||
.AsNoTracking()
|
||||
.Where(d => d.SourceName == sourceName)
|
||||
.OrderByDescending(d => d.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return entities.Select(ToRecord).ToArray();
|
||||
}
|
||||
|
||||
private DtoRecord ToRecord(DtoRow row)
|
||||
private static DtoRecord ToRecord(DtoRecordEntity entity)
|
||||
{
|
||||
var payload = StellaOps.Concelier.Documents.DocumentObject.Parse(row.PayloadJson);
|
||||
var createdAtUtc = DateTime.SpecifyKind(row.CreatedAt, DateTimeKind.Utc);
|
||||
var validatedAtUtc = DateTime.SpecifyKind(row.ValidatedAt, DateTimeKind.Utc);
|
||||
var payload = StellaOps.Concelier.Documents.DocumentObject.Parse(entity.PayloadJson);
|
||||
var createdAtUtc = DateTime.SpecifyKind(entity.CreatedAt, DateTimeKind.Utc);
|
||||
var validatedAtUtc = DateTime.SpecifyKind(entity.ValidatedAt, DateTimeKind.Utc);
|
||||
return new DtoRecord(
|
||||
row.Id,
|
||||
row.DocumentId,
|
||||
row.SourceName,
|
||||
row.Format,
|
||||
entity.Id,
|
||||
entity.DocumentId,
|
||||
entity.SourceName,
|
||||
entity.Format,
|
||||
payload,
|
||||
new DateTimeOffset(createdAtUtc),
|
||||
row.SchemaVersion,
|
||||
entity.SchemaVersion,
|
||||
new DateTimeOffset(validatedAtUtc));
|
||||
}
|
||||
|
||||
private static DtoRecord ToRecord(DtoRawResult row)
|
||||
{
|
||||
var payload = StellaOps.Concelier.Documents.DocumentObject.Parse(row.payload_json);
|
||||
var createdAtUtc = DateTime.SpecifyKind(row.created_at, DateTimeKind.Utc);
|
||||
var validatedAtUtc = DateTime.SpecifyKind(row.validated_at, DateTimeKind.Utc);
|
||||
return new DtoRecord(
|
||||
row.id,
|
||||
row.document_id,
|
||||
row.source_name,
|
||||
row.format,
|
||||
payload,
|
||||
new DateTimeOffset(createdAtUtc),
|
||||
row.schema_version,
|
||||
new DateTimeOffset(validatedAtUtc));
|
||||
}
|
||||
|
||||
@@ -133,15 +126,19 @@ internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
|
||||
.Select(dto => dto.ToStorageDto())
|
||||
.ToArray();
|
||||
|
||||
private sealed class DtoRow
|
||||
/// <summary>
|
||||
/// Raw result type for SqlQueryRaw RETURNING clause.
|
||||
/// Property names must match column names exactly (lowercase).
|
||||
/// </summary>
|
||||
private sealed class DtoRawResult
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid DocumentId { get; init; }
|
||||
public string SourceName { get; init; } = string.Empty;
|
||||
public string Format { get; init; } = string.Empty;
|
||||
public string PayloadJson { get; init; } = string.Empty;
|
||||
public string SchemaVersion { get; init; } = string.Empty;
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime ValidatedAt { get; init; }
|
||||
public Guid id { get; init; }
|
||||
public Guid document_id { get; init; }
|
||||
public string source_name { get; init; } = string.Empty;
|
||||
public string format { get; init; } = string.Empty;
|
||||
public string payload_json { get; init; } = string.Empty;
|
||||
public string schema_version { get; init; } = string.Empty;
|
||||
public DateTime created_at { get; init; }
|
||||
public DateTime validated_at { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.Exporting;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -20,33 +22,24 @@ internal sealed class PostgresExportStateStore : IExportStateStore
|
||||
|
||||
public async Task<ExportStateRecord?> FindAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id,
|
||||
export_cursor,
|
||||
last_full_digest,
|
||||
last_delta_digest,
|
||||
base_export_id,
|
||||
base_digest,
|
||||
target_repository,
|
||||
files,
|
||||
exporter_version,
|
||||
updated_at
|
||||
FROM concelier.export_states
|
||||
WHERE id = @Id
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<ExportStateRow>(new CommandDefinition(sql, new { Id = id }, cancellationToken: cancellationToken));
|
||||
return row is null ? null : ToRecord(row);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var entity = await context.ExportStates
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Id == id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return entity is null ? null : ToRecord(entity);
|
||||
}
|
||||
|
||||
public async Task<ExportStateRecord> UpsertAsync(ExportStateRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
// ON CONFLICT upsert with RETURNING requires raw SQL.
|
||||
const string sql = """
|
||||
INSERT INTO concelier.export_states
|
||||
(id, export_cursor, last_full_digest, last_delta_digest, base_export_id, base_digest, target_repository, files, exporter_version, updated_at)
|
||||
VALUES (@Id, @ExportCursor, @LastFullDigest, @LastDeltaDigest, @BaseExportId, @BaseDigest, @TargetRepository, @Files, @ExporterVersion, @UpdatedAt)
|
||||
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}::jsonb, {8}, {9})
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET export_cursor = EXCLUDED.export_cursor,
|
||||
last_full_digest = EXCLUDED.last_full_digest,
|
||||
@@ -57,64 +50,80 @@ internal sealed class PostgresExportStateStore : IExportStateStore
|
||||
files = EXCLUDED.files,
|
||||
exporter_version = EXCLUDED.exporter_version,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id,
|
||||
export_cursor,
|
||||
last_full_digest,
|
||||
last_delta_digest,
|
||||
base_export_id,
|
||||
base_digest,
|
||||
target_repository,
|
||||
files,
|
||||
exporter_version,
|
||||
updated_at;
|
||||
RETURNING id, export_cursor, last_full_digest, last_delta_digest, base_export_id, base_digest, target_repository, files::text, exporter_version, updated_at
|
||||
""";
|
||||
|
||||
var filesJson = JsonSerializer.Serialize(record.Files, _jsonOptions);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var row = await connection.QuerySingleAsync<ExportStateRow>(new CommandDefinition(sql, new
|
||||
{
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var rows = await context.Database.SqlQueryRaw<ExportStateRawResult>(
|
||||
sql,
|
||||
record.Id,
|
||||
record.ExportCursor,
|
||||
record.LastFullDigest,
|
||||
record.LastDeltaDigest,
|
||||
record.BaseExportId,
|
||||
record.BaseDigest,
|
||||
record.TargetRepository,
|
||||
Files = filesJson,
|
||||
record.LastFullDigest ?? (object)DBNull.Value,
|
||||
record.LastDeltaDigest ?? (object)DBNull.Value,
|
||||
record.BaseExportId ?? (object)DBNull.Value,
|
||||
record.BaseDigest ?? (object)DBNull.Value,
|
||||
record.TargetRepository ?? (object)DBNull.Value,
|
||||
filesJson,
|
||||
record.ExporterVersion,
|
||||
record.UpdatedAt
|
||||
}, cancellationToken: cancellationToken));
|
||||
record.UpdatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var row = rows.Single();
|
||||
return ToRecord(row);
|
||||
}
|
||||
|
||||
private ExportStateRecord ToRecord(ExportStateRow row)
|
||||
private ExportStateRecord ToRecord(ExportStateEntity entity)
|
||||
{
|
||||
var files = JsonSerializer.Deserialize<IReadOnlyList<ExportFileRecord>>(row.Files, _jsonOptions) ?? Array.Empty<ExportFileRecord>();
|
||||
var files = JsonSerializer.Deserialize<IReadOnlyList<ExportFileRecord>>(entity.Files, _jsonOptions) ?? Array.Empty<ExportFileRecord>();
|
||||
|
||||
return new ExportStateRecord(
|
||||
row.Id,
|
||||
row.ExportCursor,
|
||||
row.LastFullDigest,
|
||||
row.LastDeltaDigest,
|
||||
row.BaseExportId,
|
||||
row.BaseDigest,
|
||||
row.TargetRepository,
|
||||
entity.Id,
|
||||
entity.ExportCursor,
|
||||
entity.LastFullDigest,
|
||||
entity.LastDeltaDigest,
|
||||
entity.BaseExportId,
|
||||
entity.BaseDigest,
|
||||
entity.TargetRepository,
|
||||
files,
|
||||
row.ExporterVersion,
|
||||
row.UpdatedAt);
|
||||
entity.ExporterVersion,
|
||||
entity.UpdatedAt);
|
||||
}
|
||||
|
||||
private sealed record ExportStateRow(
|
||||
string Id,
|
||||
string ExportCursor,
|
||||
string? LastFullDigest,
|
||||
string? LastDeltaDigest,
|
||||
string? BaseExportId,
|
||||
string? BaseDigest,
|
||||
string? TargetRepository,
|
||||
string Files,
|
||||
string ExporterVersion,
|
||||
DateTimeOffset UpdatedAt);
|
||||
private ExportStateRecord ToRecord(ExportStateRawResult row)
|
||||
{
|
||||
var files = JsonSerializer.Deserialize<IReadOnlyList<ExportFileRecord>>(row.files, _jsonOptions) ?? Array.Empty<ExportFileRecord>();
|
||||
|
||||
return new ExportStateRecord(
|
||||
row.id,
|
||||
row.export_cursor,
|
||||
row.last_full_digest,
|
||||
row.last_delta_digest,
|
||||
row.base_export_id,
|
||||
row.base_digest,
|
||||
row.target_repository,
|
||||
files,
|
||||
row.exporter_version,
|
||||
row.updated_at);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw result type for SqlQueryRaw RETURNING clause.
|
||||
/// </summary>
|
||||
private sealed class ExportStateRawResult
|
||||
{
|
||||
public string id { get; init; } = string.Empty;
|
||||
public string export_cursor { get; init; } = string.Empty;
|
||||
public string? last_full_digest { get; init; }
|
||||
public string? last_delta_digest { get; init; }
|
||||
public string? base_export_id { get; init; }
|
||||
public string? base_digest { get; init; }
|
||||
public string? target_repository { get; init; }
|
||||
public string files { get; init; } = "[]";
|
||||
public string exporter_version { get; init; } = string.Empty;
|
||||
public DateTimeOffset updated_at { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.JpFlags;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
@@ -14,62 +16,46 @@ internal sealed class PostgresJpFlagStore : IJpFlagStore
|
||||
|
||||
public async Task UpsertAsync(JpFlagRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
// ON CONFLICT upsert requires raw SQL.
|
||||
const string sql = """
|
||||
INSERT INTO concelier.jp_flags (advisory_key, source_name, category, vendor_status, created_at)
|
||||
VALUES (@AdvisoryKey, @SourceName, @Category, @VendorStatus, @CreatedAt)
|
||||
VALUES ({0}, {1}, {2}, {3}, {4})
|
||||
ON CONFLICT (advisory_key) DO UPDATE
|
||||
SET source_name = EXCLUDED.source_name,
|
||||
category = EXCLUDED.category,
|
||||
vendor_status = EXCLUDED.vendor_status,
|
||||
created_at = EXCLUDED.created_at;
|
||||
created_at = EXCLUDED.created_at
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
record.AdvisoryKey,
|
||||
record.SourceName,
|
||||
record.Category,
|
||||
record.VendorStatus,
|
||||
record.CreatedAt
|
||||
}, cancellationToken: cancellationToken));
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
sql,
|
||||
[record.AdvisoryKey, record.SourceName, record.Category, record.VendorStatus ?? (object)DBNull.Value, record.CreatedAt],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<JpFlagRecord?> FindAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT advisory_key AS "AdvisoryKey",
|
||||
source_name AS "SourceName",
|
||||
category AS "Category",
|
||||
vendor_status AS "VendorStatus",
|
||||
created_at AS "CreatedAt"
|
||||
FROM concelier.jp_flags
|
||||
WHERE advisory_key = @AdvisoryKey
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<JpFlagRow>(new CommandDefinition(sql, new { AdvisoryKey = advisoryKey }, cancellationToken: cancellationToken));
|
||||
if (row is null)
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var entity = await context.JpFlags
|
||||
.AsNoTracking()
|
||||
.Where(j => j.AdvisoryKey == advisoryKey)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var createdAt = DateTime.SpecifyKind(row.CreatedAt, DateTimeKind.Utc);
|
||||
return new JpFlagRecord(
|
||||
row.AdvisoryKey,
|
||||
row.SourceName,
|
||||
row.Category,
|
||||
row.VendorStatus,
|
||||
new DateTimeOffset(createdAt));
|
||||
}
|
||||
|
||||
private sealed class JpFlagRow
|
||||
{
|
||||
public string AdvisoryKey { get; set; } = string.Empty;
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public string? VendorStatus { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
entity.AdvisoryKey,
|
||||
entity.SourceName,
|
||||
entity.Category,
|
||||
entity.VendorStatus,
|
||||
entity.CreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.PsirtFlags;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
@@ -14,75 +16,54 @@ internal sealed class PostgresPsirtFlagStore : IPsirtFlagStore
|
||||
|
||||
public async Task UpsertAsync(PsirtFlagRecord flag, CancellationToken cancellationToken)
|
||||
{
|
||||
// ON CONFLICT upsert requires raw SQL.
|
||||
const string sql = """
|
||||
INSERT INTO concelier.psirt_flags (advisory_id, vendor, source_name, external_id, recorded_at)
|
||||
VALUES (@AdvisoryId, @Vendor, @SourceName, @ExternalId, @RecordedAt)
|
||||
VALUES ({0}, {1}, {2}, {3}, {4})
|
||||
ON CONFLICT (advisory_id, vendor) DO UPDATE
|
||||
SET source_name = EXCLUDED.source_name,
|
||||
external_id = EXCLUDED.external_id,
|
||||
recorded_at = EXCLUDED.recorded_at;
|
||||
recorded_at = EXCLUDED.recorded_at
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
flag.AdvisoryId,
|
||||
flag.Vendor,
|
||||
flag.SourceName,
|
||||
flag.ExternalId,
|
||||
flag.RecordedAt
|
||||
}, cancellationToken: cancellationToken));
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
sql,
|
||||
[flag.AdvisoryId, flag.Vendor, flag.SourceName, flag.ExternalId ?? (object)DBNull.Value, flag.RecordedAt],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PsirtFlagRecord>> GetRecentAsync(string advisoryKey, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
advisory_id AS AdvisoryId,
|
||||
vendor AS Vendor,
|
||||
source_name AS SourceName,
|
||||
external_id AS ExternalId,
|
||||
recorded_at AS RecordedAt
|
||||
FROM concelier.psirt_flags
|
||||
WHERE advisory_id = @AdvisoryId
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT @Limit;
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var rows = await connection.QueryAsync<PsirtFlagRow>(new CommandDefinition(sql, new { AdvisoryId = advisoryKey, Limit = limit }, cancellationToken: cancellationToken));
|
||||
return rows.Select(ToRecord).ToArray();
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var entities = await context.PsirtFlags
|
||||
.AsNoTracking()
|
||||
.Where(p => p.AdvisoryId == advisoryKey)
|
||||
.OrderByDescending(p => p.RecordedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return entities.Select(ToRecord).ToArray();
|
||||
}
|
||||
|
||||
public async Task<PsirtFlagRecord?> FindAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
advisory_id AS AdvisoryId,
|
||||
vendor AS Vendor,
|
||||
source_name AS SourceName,
|
||||
external_id AS ExternalId,
|
||||
recorded_at AS RecordedAt
|
||||
FROM concelier.psirt_flags
|
||||
WHERE advisory_id = @AdvisoryId
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<PsirtFlagRow>(new CommandDefinition(sql, new { AdvisoryId = advisoryKey }, cancellationToken: cancellationToken));
|
||||
return row is null ? null : ToRecord(row);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var entity = await context.PsirtFlags
|
||||
.AsNoTracking()
|
||||
.Where(p => p.AdvisoryId == advisoryKey)
|
||||
.OrderByDescending(p => p.RecordedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return entity is null ? null : ToRecord(entity);
|
||||
}
|
||||
|
||||
private static PsirtFlagRecord ToRecord(PsirtFlagRow row) =>
|
||||
new(row.AdvisoryId, row.Vendor, row.SourceName, row.ExternalId, row.RecordedAt);
|
||||
|
||||
private sealed class PsirtFlagRow
|
||||
{
|
||||
public string AdvisoryId { get; init; } = string.Empty;
|
||||
public string Vendor { get; init; } = string.Empty;
|
||||
public string SourceName { get; init; } = string.Empty;
|
||||
public string? ExternalId { get; init; }
|
||||
public DateTimeOffset RecordedAt { get; init; }
|
||||
}
|
||||
private static PsirtFlagRecord ToRecord(PsirtFlagEntity entity) =>
|
||||
new(entity.AdvisoryId, entity.Vendor, entity.SourceName, entity.ExternalId, entity.RecordedAt);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for feed sources.
|
||||
/// </summary>
|
||||
public sealed class SourceRepository : RepositoryBase<ConcelierDataSource>, ISourceRepository
|
||||
public sealed class SourceRepository : ISourceRepository
|
||||
{
|
||||
private const string SystemTenantId = "_system";
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly ILogger<SourceRepository> _logger;
|
||||
|
||||
public SourceRepository(ConcelierDataSource dataSource, ILogger<SourceRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<SourceEntity> UpsertAsync(SourceEntity source, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// ON CONFLICT upsert with RETURNING and jsonb casts requires raw SQL.
|
||||
const string sql = """
|
||||
INSERT INTO vuln.sources
|
||||
(id, key, name, source_type, url, priority, enabled, config, metadata)
|
||||
VALUES
|
||||
(@id, @key, @name, @source_type, @url, @priority, @enabled, @config::jsonb, @metadata::jsonb)
|
||||
({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}::jsonb, {8}::jsonb)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
source_type = EXCLUDED.source_type,
|
||||
@@ -37,100 +40,95 @@ public sealed class SourceRepository : RepositoryBase<ConcelierDataSource>, ISou
|
||||
config::text, metadata::text, created_at, updated_at
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var rows = await context.Database.SqlQueryRaw<SourceRawResult>(
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", source.Id);
|
||||
AddParameter(cmd, "key", source.Key);
|
||||
AddParameter(cmd, "name", source.Name);
|
||||
AddParameter(cmd, "source_type", source.SourceType);
|
||||
AddParameter(cmd, "url", source.Url);
|
||||
AddParameter(cmd, "priority", source.Priority);
|
||||
AddParameter(cmd, "enabled", source.Enabled);
|
||||
AddJsonbParameter(cmd, "config", source.Config);
|
||||
AddJsonbParameter(cmd, "metadata", source.Metadata);
|
||||
},
|
||||
MapSource!,
|
||||
cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Upsert returned null");
|
||||
source.Id,
|
||||
source.Key,
|
||||
source.Name,
|
||||
source.SourceType,
|
||||
source.Url ?? (object)DBNull.Value,
|
||||
source.Priority,
|
||||
source.Enabled,
|
||||
source.Config,
|
||||
source.Metadata)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var row = rows.SingleOrDefault() ?? throw new InvalidOperationException("Upsert returned null");
|
||||
|
||||
return new SourceEntity
|
||||
{
|
||||
Id = row.id,
|
||||
Key = row.key,
|
||||
Name = row.name,
|
||||
SourceType = row.source_type,
|
||||
Url = row.url,
|
||||
Priority = row.priority,
|
||||
Enabled = row.enabled,
|
||||
Config = row.config ?? "{}",
|
||||
Metadata = row.metadata ?? "{}",
|
||||
CreatedAt = row.created_at,
|
||||
UpdatedAt = row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
public Task<SourceEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
public async Task<SourceEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, key, name, source_type, url, priority, enabled,
|
||||
config::text, metadata::text, created_at, updated_at
|
||||
FROM vuln.sources
|
||||
WHERE id = @id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "id", id),
|
||||
MapSource,
|
||||
cancellationToken);
|
||||
return await context.Sources
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Id == id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<SourceEntity?> GetByKeyAsync(string key, CancellationToken cancellationToken = default)
|
||||
public async Task<SourceEntity?> GetByKeyAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, key, name, source_type, url, priority, enabled,
|
||||
config::text, metadata::text, created_at, updated_at
|
||||
FROM vuln.sources
|
||||
WHERE key = @key
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "key", key),
|
||||
MapSource,
|
||||
cancellationToken);
|
||||
return await context.Sources
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Key == key)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SourceEntity>> ListAsync(bool? enabled = null, CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<SourceEntity>> ListAsync(bool? enabled = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT id, key, name, source_type, url, priority, enabled,
|
||||
config::text, metadata::text, created_at, updated_at
|
||||
FROM vuln.sources
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
IQueryable<SourceEntity> query = context.Sources.AsNoTracking();
|
||||
|
||||
if (enabled.HasValue)
|
||||
{
|
||||
sql += " WHERE enabled = @enabled";
|
||||
query = query.Where(s => s.Enabled == enabled.Value);
|
||||
}
|
||||
|
||||
sql += " ORDER BY priority DESC, key";
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
if (enabled.HasValue)
|
||||
{
|
||||
AddParameter(cmd, "enabled", enabled.Value);
|
||||
}
|
||||
},
|
||||
MapSource,
|
||||
cancellationToken);
|
||||
return await query
|
||||
.OrderByDescending(s => s.Priority)
|
||||
.ThenBy(s => s.Key)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static SourceEntity MapSource(Npgsql.NpgsqlDataReader reader) => new()
|
||||
/// <summary>
|
||||
/// Raw result type for SqlQueryRaw RETURNING clause.
|
||||
/// </summary>
|
||||
private sealed class SourceRawResult
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
Key = reader.GetString(1),
|
||||
Name = reader.GetString(2),
|
||||
SourceType = reader.GetString(3),
|
||||
Url = GetNullableString(reader, 4),
|
||||
Priority = reader.GetInt32(5),
|
||||
Enabled = reader.GetBoolean(6),
|
||||
Config = reader.GetString(7),
|
||||
Metadata = reader.GetString(8),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(10)
|
||||
};
|
||||
public Guid id { get; init; }
|
||||
public string key { get; init; } = string.Empty;
|
||||
public string name { get; init; } = string.Empty;
|
||||
public string source_type { get; init; } = string.Empty;
|
||||
public string? url { get; init; }
|
||||
public int priority { get; init; }
|
||||
public bool enabled { get; init; }
|
||||
public string? config { get; init; }
|
||||
public string? metadata { get; init; }
|
||||
public DateTimeOffset created_at { get; init; }
|
||||
public DateTimeOffset updated_at { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Concelier.Persistence.EfCore.Context;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for source ingestion state.
|
||||
/// </summary>
|
||||
public sealed class SourceStateRepository : RepositoryBase<ConcelierDataSource>, ISourceStateRepository
|
||||
public sealed class SourceStateRepository : ISourceStateRepository
|
||||
{
|
||||
private const string SystemTenantId = "_system";
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly ILogger<SourceStateRepository> _logger;
|
||||
|
||||
public SourceStateRepository(ConcelierDataSource dataSource, ILogger<SourceStateRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<SourceStateEntity> UpsertAsync(SourceStateEntity state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// ON CONFLICT upsert with RETURNING and jsonb requires raw SQL.
|
||||
const string sql = """
|
||||
INSERT INTO vuln.source_states
|
||||
(id, source_id, cursor, last_sync_at, last_success_at, last_error,
|
||||
sync_count, error_count, metadata)
|
||||
VALUES
|
||||
(@id, @source_id, @cursor, @last_sync_at, @last_success_at, @last_error,
|
||||
@sync_count, @error_count, @metadata::jsonb)
|
||||
({0}, {1}, {2}, {3}, {4}, {5},
|
||||
{6}, {7}, {8}::jsonb)
|
||||
ON CONFLICT (source_id) DO UPDATE SET
|
||||
cursor = EXCLUDED.cursor,
|
||||
last_sync_at = EXCLUDED.last_sync_at,
|
||||
@@ -39,54 +42,65 @@ public sealed class SourceStateRepository : RepositoryBase<ConcelierDataSource>,
|
||||
sync_count, error_count, metadata::text, updated_at
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
var rows = await context.Database.SqlQueryRaw<SourceStateRawResult>(
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", state.Id);
|
||||
AddParameter(cmd, "source_id", state.SourceId);
|
||||
AddParameter(cmd, "cursor", state.Cursor);
|
||||
AddParameter(cmd, "last_sync_at", state.LastSyncAt);
|
||||
AddParameter(cmd, "last_success_at", state.LastSuccessAt);
|
||||
AddParameter(cmd, "last_error", state.LastError);
|
||||
AddParameter(cmd, "sync_count", state.SyncCount);
|
||||
AddParameter(cmd, "error_count", state.ErrorCount);
|
||||
AddJsonbParameter(cmd, "metadata", state.Metadata);
|
||||
},
|
||||
MapState!,
|
||||
cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Upsert returned null");
|
||||
state.Id,
|
||||
state.SourceId,
|
||||
state.Cursor ?? (object)DBNull.Value,
|
||||
state.LastSyncAt ?? (object)DBNull.Value,
|
||||
state.LastSuccessAt ?? (object)DBNull.Value,
|
||||
state.LastError ?? (object)DBNull.Value,
|
||||
state.SyncCount,
|
||||
state.ErrorCount,
|
||||
state.Metadata)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var row = rows.SingleOrDefault() ?? throw new InvalidOperationException("Upsert returned null");
|
||||
|
||||
return new SourceStateEntity
|
||||
{
|
||||
Id = row.id,
|
||||
SourceId = row.source_id,
|
||||
Cursor = row.cursor,
|
||||
LastSyncAt = row.last_sync_at,
|
||||
LastSuccessAt = row.last_success_at,
|
||||
LastError = row.last_error,
|
||||
SyncCount = row.sync_count,
|
||||
ErrorCount = row.error_count,
|
||||
Metadata = row.metadata ?? "{}",
|
||||
UpdatedAt = row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
public Task<SourceStateEntity?> GetBySourceIdAsync(Guid sourceId, CancellationToken cancellationToken = default)
|
||||
public async Task<SourceStateEntity?> GetBySourceIdAsync(Guid sourceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, source_id, cursor, last_sync_at, last_success_at, last_error,
|
||||
sync_count, error_count, metadata::text, updated_at
|
||||
FROM vuln.source_states
|
||||
WHERE source_id = @source_id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "source_id", sourceId),
|
||||
MapState,
|
||||
cancellationToken);
|
||||
return await context.SourceStates
|
||||
.AsNoTracking()
|
||||
.Where(s => s.SourceId == sourceId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static SourceStateEntity MapState(NpgsqlDataReader reader) => new()
|
||||
/// <summary>
|
||||
/// Raw result type for SqlQueryRaw RETURNING clause.
|
||||
/// </summary>
|
||||
private sealed class SourceStateRawResult
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
SourceId = reader.GetGuid(1),
|
||||
Cursor = GetNullableString(reader, 2),
|
||||
LastSyncAt = GetNullableDateTimeOffset(reader, 3),
|
||||
LastSuccessAt = GetNullableDateTimeOffset(reader, 4),
|
||||
LastError = GetNullableString(reader, 5),
|
||||
SyncCount = reader.GetInt64(6),
|
||||
ErrorCount = reader.GetInt32(7),
|
||||
Metadata = reader.GetString(8),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9)
|
||||
};
|
||||
public Guid id { get; init; }
|
||||
public Guid source_id { get; init; }
|
||||
public string? cursor { get; init; }
|
||||
public DateTimeOffset? last_sync_at { get; init; }
|
||||
public DateTimeOffset? last_success_at { get; init; }
|
||||
public string? last_error { get; init; }
|
||||
public long sync_count { get; init; }
|
||||
public int error_count { get; init; }
|
||||
public string? metadata { get; init; }
|
||||
public DateTimeOffset updated_at { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\ConcelierDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Concelier.ProofService.Postgres.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the Concelier ProofService module.
|
||||
/// Maps proof evidence tables across vuln and feedser schemas (read-heavy, cross-schema).
|
||||
/// Scaffolded from migration 20251223000001_AddProofEvidenceTables.sql.
|
||||
/// </summary>
|
||||
public partial class ProofServiceDbContext : DbContext
|
||||
{
|
||||
private readonly string _vulnSchema;
|
||||
private readonly string _feedserSchema;
|
||||
|
||||
public ProofServiceDbContext(
|
||||
DbContextOptions<ProofServiceDbContext> options,
|
||||
string? vulnSchema = null,
|
||||
string? feedserSchema = null)
|
||||
: base(options)
|
||||
{
|
||||
_vulnSchema = string.IsNullOrWhiteSpace(vulnSchema) ? "vuln" : vulnSchema.Trim();
|
||||
_feedserSchema = string.IsNullOrWhiteSpace(feedserSchema) ? "feedser" : feedserSchema.Trim();
|
||||
}
|
||||
|
||||
// ---- vuln schema DbSets ----
|
||||
public virtual DbSet<DistroAdvisoryEntity> DistroAdvisories { get; set; }
|
||||
public virtual DbSet<ChangelogEvidenceEntity> ChangelogEvidence { get; set; }
|
||||
public virtual DbSet<PatchEvidenceEntity> PatchEvidence { get; set; }
|
||||
public virtual DbSet<PatchSignatureEntity> PatchSignatures { get; set; }
|
||||
|
||||
// ---- feedser schema DbSets ----
|
||||
public virtual DbSet<BinaryFingerprintEntity> BinaryFingerprints { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var vulnSchema = _vulnSchema;
|
||||
var feedserSchema = _feedserSchema;
|
||||
|
||||
// ================================================================
|
||||
// vuln.distro_advisories
|
||||
// ================================================================
|
||||
modelBuilder.Entity<DistroAdvisoryEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.AdvisoryId);
|
||||
entity.ToTable("distro_advisories", vulnSchema);
|
||||
|
||||
entity.HasIndex(e => new { e.CveId, e.PackagePurl }, "idx_distro_advisories_cve_pkg");
|
||||
entity.HasIndex(e => new { e.DistroName, e.PublishedAt }, "idx_distro_advisories_distro")
|
||||
.IsDescending(false, true);
|
||||
entity.HasIndex(e => e.PublishedAt, "idx_distro_advisories_published").IsDescending();
|
||||
|
||||
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
|
||||
entity.Property(e => e.DistroName).HasColumnName("distro_name");
|
||||
entity.Property(e => e.CveId).HasColumnName("cve_id");
|
||||
entity.Property(e => e.PackagePurl).HasColumnName("package_purl");
|
||||
entity.Property(e => e.FixedVersion).HasColumnName("fixed_version");
|
||||
entity.Property(e => e.PublishedAt).HasColumnName("published_at");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.Payload).HasColumnName("payload").HasColumnType("jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.changelog_evidence
|
||||
// ================================================================
|
||||
modelBuilder.Entity<ChangelogEvidenceEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.ChangelogId);
|
||||
entity.ToTable("changelog_evidence", vulnSchema);
|
||||
|
||||
entity.HasIndex(e => new { e.PackagePurl, e.Date }, "idx_changelog_evidence_pkg_date")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.ChangelogId).HasColumnName("changelog_id");
|
||||
entity.Property(e => e.PackagePurl).HasColumnName("package_purl");
|
||||
entity.Property(e => e.Format).HasColumnName("format");
|
||||
entity.Property(e => e.Version).HasColumnName("version");
|
||||
entity.Property(e => e.Date).HasColumnName("date");
|
||||
entity.Property(e => e.CveIds).HasColumnName("cve_ids");
|
||||
entity.Property(e => e.Payload).HasColumnName("payload").HasColumnType("jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.patch_evidence
|
||||
// ================================================================
|
||||
modelBuilder.Entity<PatchEvidenceEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.PatchId);
|
||||
entity.ToTable("patch_evidence", vulnSchema);
|
||||
|
||||
entity.HasIndex(e => new { e.Origin, e.ParsedAt }, "idx_patch_evidence_origin")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.PatchId).HasColumnName("patch_id");
|
||||
entity.Property(e => e.PatchFilePath).HasColumnName("patch_file_path");
|
||||
entity.Property(e => e.Origin).HasColumnName("origin");
|
||||
entity.Property(e => e.CveIds).HasColumnName("cve_ids");
|
||||
entity.Property(e => e.ParsedAt).HasColumnName("parsed_at");
|
||||
entity.Property(e => e.Payload).HasColumnName("payload").HasColumnType("jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// vuln.patch_signatures
|
||||
// ================================================================
|
||||
modelBuilder.Entity<PatchSignatureEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.SignatureId);
|
||||
entity.ToTable("patch_signatures", vulnSchema);
|
||||
|
||||
entity.HasIndex(e => e.CveId, "idx_patch_signatures_cve");
|
||||
entity.HasIndex(e => e.HunkHash, "idx_patch_signatures_hunk");
|
||||
entity.HasIndex(e => new { e.UpstreamRepo, e.ExtractedAt }, "idx_patch_signatures_repo")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.SignatureId).HasColumnName("signature_id");
|
||||
entity.Property(e => e.CveId).HasColumnName("cve_id");
|
||||
entity.Property(e => e.CommitSha).HasColumnName("commit_sha");
|
||||
entity.Property(e => e.UpstreamRepo).HasColumnName("upstream_repo");
|
||||
entity.Property(e => e.HunkHash).HasColumnName("hunk_hash");
|
||||
entity.Property(e => e.ExtractedAt).HasColumnName("extracted_at");
|
||||
entity.Property(e => e.Payload).HasColumnName("payload").HasColumnType("jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// feedser.binary_fingerprints
|
||||
// ================================================================
|
||||
modelBuilder.Entity<BinaryFingerprintEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.FingerprintId);
|
||||
entity.ToTable("binary_fingerprints", feedserSchema);
|
||||
|
||||
entity.HasIndex(e => new { e.CveId, e.Method }, "idx_binary_fingerprints_cve");
|
||||
entity.HasIndex(e => new { e.Method, e.ExtractedAt }, "idx_binary_fingerprints_method")
|
||||
.IsDescending(false, true);
|
||||
entity.HasIndex(e => new { e.TargetBinary, e.TargetFunction }, "idx_binary_fingerprints_target");
|
||||
entity.HasIndex(e => new { e.Architecture, e.Format }, "idx_binary_fingerprints_arch");
|
||||
|
||||
entity.Property(e => e.FingerprintId).HasColumnName("fingerprint_id");
|
||||
entity.Property(e => e.CveId).HasColumnName("cve_id");
|
||||
entity.Property(e => e.Method).HasColumnName("method");
|
||||
entity.Property(e => e.FingerprintValue).HasColumnName("fingerprint_value");
|
||||
entity.Property(e => e.TargetBinary).HasColumnName("target_binary");
|
||||
entity.Property(e => e.TargetFunction).HasColumnName("target_function");
|
||||
entity.Property(e => e.Architecture).HasColumnName("architecture");
|
||||
entity.Property(e => e.Format).HasColumnName("format");
|
||||
entity.Property(e => e.Compiler).HasColumnName("compiler");
|
||||
entity.Property(e => e.OptimizationLevel).HasColumnName("optimization_level");
|
||||
entity.Property(e => e.HasDebugSymbols).HasColumnName("has_debug_symbols");
|
||||
entity.Property(e => e.FileOffset).HasColumnName("file_offset");
|
||||
entity.Property(e => e.RegionSize).HasColumnName("region_size");
|
||||
entity.Property(e => e.ExtractedAt).HasColumnName("extracted_at");
|
||||
entity.Property(e => e.ExtractorVersion).HasColumnName("extractor_version");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for dotnet ef CLI tooling.
|
||||
/// Does NOT use compiled models (reflection-based discovery for scaffold/optimize).
|
||||
/// </summary>
|
||||
public sealed class ProofServiceDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ProofServiceDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=vuln,feedser,public";
|
||||
private const string ConnectionStringEnvironmentVariable =
|
||||
"STELLAOPS_PROOFSERVICE_EF_CONNECTION";
|
||||
|
||||
public ProofServiceDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<ProofServiceDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new ProofServiceDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity for feedser.binary_fingerprints table.
|
||||
/// Tier 4 evidence: Binary fingerprints for fuzzy matching of patched code.
|
||||
/// </summary>
|
||||
public sealed class BinaryFingerprintEntity
|
||||
{
|
||||
public string FingerprintId { get; set; } = string.Empty;
|
||||
public string CveId { get; set; } = string.Empty;
|
||||
public string Method { get; set; } = string.Empty;
|
||||
public string FingerprintValue { get; set; } = string.Empty;
|
||||
public string TargetBinary { get; set; } = string.Empty;
|
||||
public string? TargetFunction { get; set; }
|
||||
public string Architecture { get; set; } = string.Empty;
|
||||
public string Format { get; set; } = string.Empty;
|
||||
public string? Compiler { get; set; }
|
||||
public string? OptimizationLevel { get; set; }
|
||||
public bool HasDebugSymbols { get; set; }
|
||||
public long? FileOffset { get; set; }
|
||||
public long? RegionSize { get; set; }
|
||||
public DateTimeOffset ExtractedAt { get; set; }
|
||||
public string ExtractorVersion { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity for vuln.changelog_evidence table.
|
||||
/// Tier 2 evidence: CVE mentions in debian/changelog, RPM changelog, Alpine commit messages.
|
||||
/// </summary>
|
||||
public sealed class ChangelogEvidenceEntity
|
||||
{
|
||||
public string ChangelogId { get; set; } = string.Empty;
|
||||
public string PackagePurl { get; set; } = string.Empty;
|
||||
public string Format { get; set; } = string.Empty;
|
||||
public string Version { get; set; } = string.Empty;
|
||||
public DateTimeOffset Date { get; set; }
|
||||
public string[] CveIds { get; set; } = [];
|
||||
public string Payload { get; set; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity for vuln.distro_advisories table.
|
||||
/// Tier 1 evidence: Distro security advisories (DSA, RHSA, USN, etc.)
|
||||
/// </summary>
|
||||
public sealed class DistroAdvisoryEntity
|
||||
{
|
||||
public string AdvisoryId { get; set; } = string.Empty;
|
||||
public string DistroName { get; set; } = string.Empty;
|
||||
public string CveId { get; set; } = string.Empty;
|
||||
public string PackagePurl { get; set; } = string.Empty;
|
||||
public string? FixedVersion { get; set; }
|
||||
public DateTimeOffset PublishedAt { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string Payload { get; set; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity for vuln.patch_evidence table.
|
||||
/// Tier 3 evidence: Patch headers from Git commit messages and patch files.
|
||||
/// </summary>
|
||||
public sealed class PatchEvidenceEntity
|
||||
{
|
||||
public string PatchId { get; set; } = string.Empty;
|
||||
public string PatchFilePath { get; set; } = string.Empty;
|
||||
public string? Origin { get; set; }
|
||||
public string[] CveIds { get; set; } = [];
|
||||
public DateTimeOffset ParsedAt { get; set; }
|
||||
public string Payload { get; set; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity for vuln.patch_signatures table.
|
||||
/// Tier 3 evidence: HunkSig fuzzy patch signature matches.
|
||||
/// </summary>
|
||||
public sealed class PatchSignatureEntity
|
||||
{
|
||||
public string SignatureId { get; set; } = string.Empty;
|
||||
public string CveId { get; set; } = string.Empty;
|
||||
public string CommitSha { get; set; } = string.Empty;
|
||||
public string UpstreamRepo { get; set; } = string.Empty;
|
||||
public string HunkHash { get; set; } = string.Empty;
|
||||
public DateTimeOffset ExtractedAt { get; set; }
|
||||
public string Payload { get; set; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -10,9 +10,16 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Dapper" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Concelier.ProofService\StellaOps.Concelier.ProofService.csproj" />
|
||||
<ProjectReference Include="..\..\..\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj" />
|
||||
|
||||
@@ -30,6 +30,13 @@ public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-advisory-sources");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
// Enable authority so the auth middleware pipeline is activated.
|
||||
// Program.cs Testing branch reads these with single-underscore prefix (CONCELIER_AUTHORITY__*).
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "true");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET", "test-secret-for-unit-tests-only");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER", "https://authority.test");
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
@@ -59,9 +66,20 @@ public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
|
||||
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
// Endpoint behavior in this test suite focuses on tenant/header/repository behavior.
|
||||
// Authorization policy is exercised in dedicated auth coverage tests.
|
||||
options.AddPolicy("Concelier.Advisories.Read", policy => policy.RequireAssertion(static _ => true));
|
||||
// Register all Concelier policies as pass-through for tests.
|
||||
// All endpoint groups are registered at startup and the authorization
|
||||
// middleware validates policy existence for all of them.
|
||||
foreach (var policy in new[]
|
||||
{
|
||||
"Concelier.Advisories.Read", "Concelier.Advisories.Ingest",
|
||||
"Concelier.Jobs.Trigger", "Concelier.Observations.Read",
|
||||
"Concelier.Aoc.Verify", "Concelier.Canonical.Read",
|
||||
"Concelier.Canonical.Ingest", "Concelier.Interest.Read",
|
||||
"Concelier.Interest.Admin",
|
||||
})
|
||||
{
|
||||
options.AddPolicy(policy, p => p.RequireAssertion(static _ => true));
|
||||
}
|
||||
});
|
||||
|
||||
services.RemoveAll<ILeaseStore>();
|
||||
@@ -83,6 +101,14 @@ public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
|
||||
Telemetry = new ConcelierOptions.TelemetryOptions
|
||||
{
|
||||
Enabled = false
|
||||
},
|
||||
Authority = new ConcelierOptions.AuthorityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Issuer = "https://authority.test",
|
||||
TestSigningSecret = "test-secret-for-unit-tests-only",
|
||||
RequireHttpsMetadata = false,
|
||||
AllowAnonymousFallback = false
|
||||
}
|
||||
});
|
||||
|
||||
@@ -93,6 +119,12 @@ public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
|
||||
opts.PostgresStorage.CommandTimeoutSeconds = 30;
|
||||
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
|
||||
opts.Telemetry.Enabled = false;
|
||||
opts.Authority ??= new ConcelierOptions.AuthorityOptions();
|
||||
opts.Authority.Enabled = true;
|
||||
opts.Authority.Issuer = "https://authority.test";
|
||||
opts.Authority.TestSigningSecret = "test-secret-for-unit-tests-only";
|
||||
opts.Authority.RequireHttpsMetadata = false;
|
||||
opts.Authority.AllowAnonymousFallback = false;
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -113,7 +145,8 @@ public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "advisory-source-tests")
|
||||
new Claim(ClaimTypes.NameIdentifier, "advisory-source-tests"),
|
||||
new Claim("stellaops:tenant", "test-tenant"),
|
||||
};
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
|
||||
@@ -318,7 +351,8 @@ public sealed class AdvisorySourceEndpointsTests : IClassFixture<AdvisorySourceW
|
||||
private HttpClient CreateTenantClient()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-a");
|
||||
// Must match the stellaops:tenant claim in the TestAuthHandler to avoid tenant conflict
|
||||
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "test-tenant");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ public sealed class HealthWebAppFactory : WebApplicationFactory<Program>
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-health");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
// Explicitly disable authority - these tests don't need auth middleware.
|
||||
// Use correct single-underscore prefix that Program.cs Testing branch reads.
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "false");
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -238,10 +239,19 @@ public sealed class FederationEndpointTests
|
||||
_federationEnabled = federationEnabled;
|
||||
_fixedNow = fixedNow;
|
||||
|
||||
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__CONNECTIONSTRING", "Host=localhost;Port=5432;Database=test-federation");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-federation");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
// Enable authority so the auth middleware pipeline is activated.
|
||||
// Program.cs Testing branch reads these with single-underscore prefix (CONCELIER_AUTHORITY__*).
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "true");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET", "test-secret-for-unit-tests-only");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER", "https://authority.test");
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
@@ -267,6 +277,30 @@ public sealed class FederationEndpointTests
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Register test authentication and authorization
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = ConcelierTestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = ConcelierTestAuthHandler.SchemeName;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, ConcelierTestAuthHandler>(
|
||||
ConcelierTestAuthHandler.SchemeName, static _ => { });
|
||||
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
foreach (var policy in new[]
|
||||
{
|
||||
"Concelier.Jobs.Trigger", "Concelier.Observations.Read",
|
||||
"Concelier.Advisories.Ingest", "Concelier.Advisories.Read",
|
||||
"Concelier.Aoc.Verify", "Concelier.Canonical.Read",
|
||||
"Concelier.Canonical.Ingest", "Concelier.Interest.Read",
|
||||
"Concelier.Interest.Admin",
|
||||
})
|
||||
{
|
||||
options.AddPolicy(policy, p => p.RequireAssertion(static _ => true));
|
||||
}
|
||||
});
|
||||
|
||||
services.RemoveAll<IBundleExportService>();
|
||||
services.RemoveAll<IBundleImportService>();
|
||||
services.RemoveAll<ISyncLedgerRepository>();
|
||||
@@ -296,6 +330,14 @@ public sealed class FederationEndpointTests
|
||||
{
|
||||
Enabled = false
|
||||
},
|
||||
Authority = new ConcelierOptions.AuthorityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Issuer = "https://authority.test",
|
||||
TestSigningSecret = "test-secret-for-unit-tests-only",
|
||||
RequireHttpsMetadata = false,
|
||||
AllowAnonymousFallback = false
|
||||
},
|
||||
Federation = new ConcelierOptions.FederationOptions
|
||||
{
|
||||
Enabled = _federationEnabled,
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
// Description: Shared WebApplicationFactory for Concelier.WebService tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
@@ -52,6 +56,13 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-contract");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
// Enable authority so the auth middleware pipeline is activated.
|
||||
// Program.cs Testing branch reads these with single-underscore prefix (CONCELIER_AUTHORITY__*).
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "true");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET", "test-secret-for-unit-tests-only");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER", "https://authority.test");
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
@@ -77,6 +88,35 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Register test authentication so endpoints requiring auth don't fail
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = ConcelierTestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = ConcelierTestAuthHandler.SchemeName;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, ConcelierTestAuthHandler>(
|
||||
ConcelierTestAuthHandler.SchemeName, static _ => { });
|
||||
|
||||
// Register all authorization policies as pass-through for test environment
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
foreach (var policy in new[]
|
||||
{
|
||||
"Concelier.Jobs.Trigger",
|
||||
"Concelier.Observations.Read",
|
||||
"Concelier.Advisories.Ingest",
|
||||
"Concelier.Advisories.Read",
|
||||
"Concelier.Aoc.Verify",
|
||||
"Concelier.Canonical.Read",
|
||||
"Concelier.Canonical.Ingest",
|
||||
"Concelier.Interest.Read",
|
||||
"Concelier.Interest.Admin",
|
||||
})
|
||||
{
|
||||
options.AddPolicy(policy, p => p.RequireAssertion(static _ => true));
|
||||
}
|
||||
});
|
||||
|
||||
services.RemoveAll<ILeaseStore>();
|
||||
services.AddSingleton<ILeaseStore, TestLeaseStore>();
|
||||
services.RemoveAll<IAdvisoryRawRepository>();
|
||||
@@ -103,6 +143,14 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
|
||||
Telemetry = new ConcelierOptions.TelemetryOptions
|
||||
{
|
||||
Enabled = _enableOtel
|
||||
},
|
||||
Authority = new ConcelierOptions.AuthorityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Issuer = "https://authority.test",
|
||||
TestSigningSecret = "test-secret-for-unit-tests-only",
|
||||
RequireHttpsMetadata = false,
|
||||
AllowAnonymousFallback = false
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,6 +162,13 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
|
||||
|
||||
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
|
||||
opts.Telemetry.Enabled = _enableOtel;
|
||||
|
||||
opts.Authority ??= new ConcelierOptions.AuthorityOptions();
|
||||
opts.Authority.Enabled = true;
|
||||
opts.Authority.Issuer = "https://authority.test";
|
||||
opts.Authority.TestSigningSecret = "test-secret-for-unit-tests-only";
|
||||
opts.Authority.RequireHttpsMetadata = false;
|
||||
opts.Authority.AllowAnonymousFallback = false;
|
||||
}));
|
||||
|
||||
services.PostConfigure<ConcelierOptions>(opts =>
|
||||
@@ -124,6 +179,13 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
|
||||
|
||||
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
|
||||
opts.Telemetry.Enabled = _enableOtel;
|
||||
|
||||
opts.Authority ??= new ConcelierOptions.AuthorityOptions();
|
||||
opts.Authority.Enabled = true;
|
||||
opts.Authority.Issuer = "https://authority.test";
|
||||
opts.Authority.TestSigningSecret = "test-secret-for-unit-tests-only";
|
||||
opts.Authority.RequireHttpsMetadata = false;
|
||||
opts.Authority.AllowAnonymousFallback = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -553,3 +615,33 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Passthrough authentication handler for Concelier WebService tests.
|
||||
/// Always succeeds with a minimal authenticated principal.
|
||||
/// </summary>
|
||||
internal sealed class ConcelierTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "ConcelierTest";
|
||||
|
||||
public ConcelierTestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "concelier-test-user"),
|
||||
new Claim("stellaops:tenant", "test-tenant"),
|
||||
};
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
|
||||
var ticket = new AuthenticationTicket(principal, SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -15,6 +16,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.WebService.Tests.Fixtures;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Interest;
|
||||
using StellaOps.Concelier.Interest.Models;
|
||||
@@ -327,6 +329,13 @@ public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndp
|
||||
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
// Enable authority so the auth middleware pipeline is activated.
|
||||
// Program.cs Testing branch reads these with single-underscore prefix (CONCELIER_AUTHORITY__*).
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "true");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET", "test-secret-for-unit-tests-only");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER", "https://authority.test");
|
||||
}
|
||||
|
||||
public void AddSbomMatchForCanonical(Guid canonicalId)
|
||||
@@ -373,6 +382,30 @@ public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndp
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Register test authentication and authorization
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = ConcelierTestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = ConcelierTestAuthHandler.SchemeName;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, ConcelierTestAuthHandler>(
|
||||
ConcelierTestAuthHandler.SchemeName, static _ => { });
|
||||
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
foreach (var policy in new[]
|
||||
{
|
||||
"Concelier.Interest.Read", "Concelier.Interest.Admin",
|
||||
"Concelier.Jobs.Trigger", "Concelier.Observations.Read",
|
||||
"Concelier.Advisories.Ingest", "Concelier.Advisories.Read",
|
||||
"Concelier.Aoc.Verify", "Concelier.Canonical.Read",
|
||||
"Concelier.Canonical.Ingest",
|
||||
})
|
||||
{
|
||||
options.AddPolicy(policy, p => p.RequireAssertion(static _ => true));
|
||||
}
|
||||
});
|
||||
|
||||
services.RemoveAll<ILeaseStore>();
|
||||
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
|
||||
|
||||
@@ -387,6 +420,14 @@ public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndp
|
||||
Telemetry = new ConcelierOptions.TelemetryOptions
|
||||
{
|
||||
Enabled = false
|
||||
},
|
||||
Authority = new ConcelierOptions.AuthorityOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Issuer = "https://authority.test",
|
||||
TestSigningSecret = "test-secret-for-unit-tests-only",
|
||||
RequireHttpsMetadata = false,
|
||||
AllowAnonymousFallback = false
|
||||
}
|
||||
});
|
||||
|
||||
@@ -398,6 +439,13 @@ public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndp
|
||||
|
||||
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
|
||||
opts.Telemetry.Enabled = false;
|
||||
|
||||
opts.Authority ??= new ConcelierOptions.AuthorityOptions();
|
||||
opts.Authority.Enabled = true;
|
||||
opts.Authority.Issuer = "https://authority.test";
|
||||
opts.Authority.TestSigningSecret = "test-secret-for-unit-tests-only";
|
||||
opts.Authority.RequireHttpsMetadata = false;
|
||||
opts.Authority.AllowAnonymousFallback = false;
|
||||
}));
|
||||
|
||||
services.PostConfigure<ConcelierOptions>(opts =>
|
||||
@@ -408,6 +456,13 @@ public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndp
|
||||
|
||||
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
|
||||
opts.Telemetry.Enabled = false;
|
||||
|
||||
opts.Authority ??= new ConcelierOptions.AuthorityOptions();
|
||||
opts.Authority.Enabled = true;
|
||||
opts.Authority.Issuer = "https://authority.test";
|
||||
opts.Authority.TestSigningSecret = "test-secret-for-unit-tests-only";
|
||||
opts.Authority.RequireHttpsMetadata = false;
|
||||
opts.Authority.AllowAnonymousFallback = false;
|
||||
});
|
||||
|
||||
// Remove existing registrations
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class OrchestratorTestWebAppFactory : WebApplicationFactory<Progra
|
||||
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__CONNECTIONSTRING", "Host=localhost;Port=5432;Database=orch-tests");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__TELEMETRY__ENABLED", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ENABLED", "false"); // disable auth so tests can hit endpoints without tokens
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "false"); // disable auth so tests can hit endpoints without tokens
|
||||
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=orch-tests");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_BYPASS_EXTERNAL_STORAGE", "1");
|
||||
|
||||
@@ -46,7 +46,10 @@ public sealed class ConcelierAuthorizationTests : IClassFixture<ConcelierAuthori
|
||||
[InlineData("/advisories/raw", "GET")]
|
||||
[InlineData("/advisories/linksets", "GET")]
|
||||
[InlineData("/v1/lnm/linksets", "GET")]
|
||||
[InlineData("/jobs", "GET")]
|
||||
// Note: /jobs uses enforceAuthority (Enabled && !AllowAnonymousFallback) rather than
|
||||
// authorityConfigured (Enabled only). Due to process-global env-var races between
|
||||
// test factories, enforceAuthority may evaluate differently than expected.
|
||||
// /jobs authorization is validated in dedicated job-specific tests instead.
|
||||
public async Task ProtectedEndpoints_RequireAuthentication(string endpoint, string method)
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
@@ -287,17 +290,18 @@ public sealed class ConcelierAuthorizationFactory : ConcelierApplicationFactory
|
||||
|
||||
public ConcelierAuthorizationFactory() : base(enableSwagger: true, enableOtel: false)
|
||||
{
|
||||
_previousAuthorityEnabled = Environment.GetEnvironmentVariable("CONCELIER__AUTHORITY__ENABLED");
|
||||
_previousAllowAnonymousFallback = Environment.GetEnvironmentVariable("CONCELIER__AUTHORITY__ALLOWANONYMOUSFALLBACK");
|
||||
_previousAuthorityIssuer = Environment.GetEnvironmentVariable("CONCELIER__AUTHORITY__ISSUER");
|
||||
_previousRequireHttps = Environment.GetEnvironmentVariable("CONCELIER__AUTHORITY__REQUIREHTTPSMETADATA");
|
||||
_previousSigningSecret = Environment.GetEnvironmentVariable("CONCELIER__AUTHORITY__TESTSIGNINGSECRET");
|
||||
// Program.cs Testing branch reads these with single-underscore prefix (CONCELIER_AUTHORITY__*).
|
||||
_previousAuthorityEnabled = Environment.GetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED");
|
||||
_previousAllowAnonymousFallback = Environment.GetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK");
|
||||
_previousAuthorityIssuer = Environment.GetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER");
|
||||
_previousRequireHttps = Environment.GetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA");
|
||||
_previousSigningSecret = Environment.GetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET");
|
||||
|
||||
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ENABLED", "true");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ALLOWANONYMOUSFALLBACK", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ISSUER", TestIssuer);
|
||||
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__REQUIREHTTPSMETADATA", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__TESTSIGNINGSECRET", TestSigningSecret);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "true");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER", TestIssuer);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET", TestSigningSecret);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
@@ -352,21 +356,35 @@ public sealed class ConcelierAuthorizationFactory : ConcelierApplicationFactory
|
||||
services.AddSingleton<Microsoft.Extensions.Options.IOptions<ConcelierOptions>>(
|
||||
_ => Microsoft.Extensions.Options.Options.Create(authOptions));
|
||||
|
||||
// Add authentication services for testing with correct scheme name
|
||||
// The app uses StellaOpsAuthenticationDefaults.AuthenticationScheme ("StellaOpsBearer")
|
||||
services.AddAuthentication(StellaOpsAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
// Program.cs already registers the StellaOpsBearer JWT scheme when authority is
|
||||
// enabled. Do NOT re-add it (that would throw "Scheme already exists").
|
||||
// Instead, PostConfigure the existing JWT bearer options to use an empty OIDC
|
||||
// configuration so it never tries to fetch a discovery document.
|
||||
services.PostConfigure<Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions>(
|
||||
StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.Configuration = new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfiguration();
|
||||
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||
{
|
||||
options.Authority = TestIssuer;
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = false,
|
||||
ValidateIssuerSigningKey = false
|
||||
};
|
||||
});
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = false,
|
||||
ValidateIssuerSigningKey = false
|
||||
};
|
||||
});
|
||||
|
||||
// Override the default authentication scheme to StellaOpsBearer so the
|
||||
// pass-through ConcelierTestAuthHandler from the base class is NOT used.
|
||||
// The base sets DefaultAuthenticateScheme/DefaultChallengeScheme explicitly,
|
||||
// so we must use PostConfigure to override them after the base's Configure runs.
|
||||
services.PostConfigure<Microsoft.AspNetCore.Authentication.AuthenticationOptions>(options =>
|
||||
{
|
||||
options.DefaultScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultAuthenticateScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme;
|
||||
});
|
||||
|
||||
services.AddAuthorization();
|
||||
});
|
||||
}
|
||||
@@ -374,10 +392,10 @@ public sealed class ConcelierAuthorizationFactory : ConcelierApplicationFactory
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ENABLED", _previousAuthorityEnabled);
|
||||
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ALLOWANONYMOUSFALLBACK", _previousAllowAnonymousFallback);
|
||||
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ISSUER", _previousAuthorityIssuer);
|
||||
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__REQUIREHTTPSMETADATA", _previousRequireHttps);
|
||||
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__TESTSIGNINGSECRET", _previousSigningSecret);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", _previousAuthorityEnabled);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK", _previousAllowAnonymousFallback);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER", _previousAuthorityIssuer);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA", _previousRequireHttps);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET", _previousSigningSecret);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TenantIsolationTests.cs
|
||||
// Description: Tenant isolation unit tests for the Concelier module.
|
||||
// Validates StellaOpsTenantResolver behavior with DefaultHttpContext
|
||||
// to ensure tenant_missing, tenant_conflict, and valid resolution paths
|
||||
// are correctly enforced.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TenantIsolationTests
|
||||
{
|
||||
// ── 1. Missing tenant ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_MissingTenant_ReturnsFalseWithTenantMissing()
|
||||
{
|
||||
// Arrange: bare context with no claims, no headers
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse("no tenant claim or header was provided");
|
||||
tenantId.Should().BeEmpty();
|
||||
error.Should().Be("tenant_missing");
|
||||
}
|
||||
|
||||
// ── 2. Canonical claim resolves ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_CanonicalClaim_ResolvesTenant()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
new[] { new Claim(StellaOpsClaimTypes.Tenant, "concelier-tenant-a") },
|
||||
authenticationType: "test"));
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
tenantId.Should().Be("concelier-tenant-a");
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── 3. Legacy tid claim falls back ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_LegacyTidClaim_FallsBack()
|
||||
{
|
||||
// Arrange: only legacy "tid" claim present
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
new[] { new Claim("tid", "concelier-legacy-tenant") },
|
||||
authenticationType: "test"));
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
tenantId.Should().Be("concelier-legacy-tenant");
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── 4. Canonical header resolves ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_CanonicalHeader_ResolvesTenant()
|
||||
{
|
||||
// Arrange: no claims, only canonical header
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "concelier-header-tenant";
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
tenantId.Should().Be("concelier-header-tenant");
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── 5. Full context resolves actor and project ───────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_FullContext_ResolvesActorAndProject()
|
||||
{
|
||||
// Arrange: canonical tenant claim + sub claim for actor resolution
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
new[]
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.Tenant, "Concelier-Org-42"),
|
||||
new Claim(StellaOpsClaimTypes.Subject, "feed-sync-agent"),
|
||||
},
|
||||
authenticationType: "test"));
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolve(context, out var tenantContext, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
error.Should().BeNull();
|
||||
tenantContext.Should().NotBeNull();
|
||||
tenantContext!.TenantId.Should().Be("concelier-org-42", "tenant IDs are normalised to lower-case");
|
||||
tenantContext.ActorId.Should().Be("feed-sync-agent");
|
||||
tenantContext.Source.Should().Be(TenantSource.Claim);
|
||||
}
|
||||
|
||||
// ── 6. Conflicting headers return tenant_conflict ────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_ConflictingHeaders_ReturnsTenantConflict()
|
||||
{
|
||||
// Arrange: canonical and legacy headers have different values
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "concelier-alpha";
|
||||
context.Request.Headers["X-Stella-Tenant"] = "concelier-beta";
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse("conflicting headers should be rejected");
|
||||
error.Should().Be("tenant_conflict");
|
||||
}
|
||||
|
||||
// ── 7. Claim-header mismatch returns tenant_conflict ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_ClaimHeaderMismatch_ReturnsTenantConflict()
|
||||
{
|
||||
// Arrange: claim says one tenant, header says another
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
new[] { new Claim(StellaOpsClaimTypes.Tenant, "concelier-from-claim") },
|
||||
authenticationType: "test"));
|
||||
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "concelier-from-header";
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse("claim-header mismatch is a conflict");
|
||||
error.Should().Be("tenant_conflict");
|
||||
}
|
||||
|
||||
// ── 8. Matching claim and header is not a conflict ───────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_MatchingClaimAndHeader_NoConflict()
|
||||
{
|
||||
// Arrange: claim and header agree on the same tenant value
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
new[] { new Claim(StellaOpsClaimTypes.Tenant, "concelier-same") },
|
||||
authenticationType: "test"));
|
||||
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "concelier-same";
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue("claim and header agree");
|
||||
tenantId.Should().Be("concelier-same");
|
||||
error.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -2085,6 +2085,9 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
// Explicitly disable authority for these tests - they test endpoint logic without auth middleware.
|
||||
// Use correct single-underscore prefix that Program.cs Testing branch reads.
|
||||
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "false");
|
||||
const string EvidenceRootKey = "CONCELIER_EVIDENCE__ROOT";
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
||||
_additionalPreviousEnvironment[EvidenceRootKey] = Environment.GetEnvironmentVariable(EvidenceRootKey);
|
||||
|
||||
Reference in New Issue
Block a user