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:
21
src/SbomService/StellaOps.SbomService/Auth/SbomPolicies.cs
Normal file
21
src/SbomService/StellaOps.SbomService/Auth/SbomPolicies.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.SbomService.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Named authorization policy constants for the SBOM service.
|
||||
/// SbomService uses the internal HeaderAuthenticationHandler (x-tenant-id header) which
|
||||
/// does not issue scope claims. Policies require an authenticated tenant context.
|
||||
/// Scope enforcement is applied at the infrastructure level via the header auth scheme.
|
||||
/// </summary>
|
||||
internal static class SbomPolicies
|
||||
{
|
||||
/// <summary>Policy for querying SBOM data (paths, versions, ledger, lineage). Requires authenticated tenant context.</summary>
|
||||
public const string Read = "Sbom.Read";
|
||||
|
||||
/// <summary>Policy for mutating SBOM data (upload, entrypoints, orchestrator). Requires authenticated tenant context.</summary>
|
||||
public const string Write = "Sbom.Write";
|
||||
|
||||
/// <summary>Policy for internal/operational endpoints (events, backfill, retention). Requires authenticated tenant context.</summary>
|
||||
public const string Internal = "Sbom.Internal";
|
||||
}
|
||||
@@ -27,7 +27,13 @@ builder.Services.AddSingleton<IGuidProvider>(SystemGuidProvider.Instance);
|
||||
|
||||
builder.Services.AddAuthentication(HeaderAuthenticationHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, HeaderAuthenticationHandler>(HeaderAuthenticationHandler.SchemeName, _ => { });
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
// SbomService uses HeaderAuthenticationHandler (x-tenant-id). Policies require authenticated tenant context.
|
||||
options.AddPolicy(SbomPolicies.Read, policy => policy.RequireAuthenticatedUser());
|
||||
options.AddPolicy(SbomPolicies.Write, policy => policy.RequireAuthenticatedUser());
|
||||
options.AddPolicy(SbomPolicies.Internal, policy => policy.RequireAuthenticatedUser());
|
||||
});
|
||||
|
||||
// Register SBOM query services using file-backed fixtures when present; fallback to in-memory seeds.
|
||||
builder.Services.AddSingleton<IComponentLookupRepository>(sp =>
|
||||
@@ -253,8 +259,14 @@ app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
app.MapGet("/readyz", () => Results.Ok(new { status = "warming" }));
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
|
||||
.WithName("SbomHealthz")
|
||||
.WithDescription("Returns liveness status of the SBOM service. Always returns 200 OK with status 'ok' when the process is running. Used by infrastructure liveness probes.")
|
||||
.AllowAnonymous();
|
||||
app.MapGet("/readyz", () => Results.Ok(new { status = "warming" }))
|
||||
.WithName("SbomReadyz")
|
||||
.WithDescription("Returns readiness status of the SBOM service. Returns 200 with status 'warming' while the service is starting up. Used by infrastructure readiness probes.")
|
||||
.AllowAnonymous();
|
||||
|
||||
app.MapGet("/entrypoints", async Task<IResult> (
|
||||
[FromServices] IEntrypointRepository repo,
|
||||
@@ -272,7 +284,10 @@ app.MapGet("/entrypoints", async Task<IResult> (
|
||||
|
||||
var items = await repo.ListAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(new EntrypointListResponse(tenantId, items));
|
||||
});
|
||||
})
|
||||
.WithName("ListSbomEntrypoints")
|
||||
.WithDescription("Returns all registered service entrypoints for the tenant, listing artifact, service, path, scope, and runtime flag for each. The tenant query parameter is required.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapPost("/entrypoints", async Task<IResult> (
|
||||
[FromServices] IEntrypointRepository repo,
|
||||
@@ -306,7 +321,10 @@ app.MapPost("/entrypoints", async Task<IResult> (
|
||||
|
||||
var items = await repo.ListAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(new EntrypointListResponse(tenantId, items));
|
||||
});
|
||||
})
|
||||
.WithName("UpsertSbomEntrypoint")
|
||||
.WithDescription("Creates or updates a service entrypoint for the tenant linking an artifact to a service path. Returns the full updated entrypoint list for the tenant. Requires tenant, artifact, service, and path fields.")
|
||||
.RequireAuthorization(SbomPolicies.Write);
|
||||
|
||||
app.MapGet("/console/sboms", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
@@ -351,7 +369,10 @@ app.MapGet("/console/sboms", async Task<IResult> (
|
||||
});
|
||||
|
||||
return Results.Ok(result.Result);
|
||||
});
|
||||
})
|
||||
.WithName("ListConsoleSboms")
|
||||
.WithDescription("Returns a paginated SBOM catalog for the console UI, optionally filtered by artifact name, license, scope, and asset tag. Supports cursor-based pagination. Limit must be between 1 and 200.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/components/lookup", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
@@ -400,7 +421,10 @@ app.MapGet("/components/lookup", async Task<IResult> (
|
||||
});
|
||||
|
||||
return Results.Ok(result.Result);
|
||||
});
|
||||
})
|
||||
.WithName("LookupSbomComponent")
|
||||
.WithDescription("Looks up all SBOM entries that include a specific component PURL, optionally filtered by artifact. Returns paginated results with cursor support. Requires purl query parameter. Limit must be between 1 and 200.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/sbom/context", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
@@ -462,7 +486,10 @@ app.MapGet("/sbom/context", async Task<IResult> (
|
||||
includeBlast);
|
||||
|
||||
return Results.Ok(response);
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomContext")
|
||||
.WithDescription("Returns an assembled SBOM context for an artifact including version timeline and dependency paths for a specific PURL. Combines timeline and path data into a single response for UI rendering. Requires artifactId query parameter.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/sbom/paths", async Task<IResult> (
|
||||
[FromServices] IServiceProvider services,
|
||||
@@ -511,7 +538,10 @@ app.MapGet("/sbom/paths", async Task<IResult> (
|
||||
});
|
||||
|
||||
return Results.Ok(result.Result);
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomPaths")
|
||||
.WithDescription("Returns paginated dependency paths for a specific component PURL across SBOMs, optionally filtered by artifact, scope, and environment. Requires purl query parameter. Limit must be between 1 and 200.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/sbom/versions", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
@@ -555,7 +585,10 @@ app.MapGet("/sbom/versions", async Task<IResult> (
|
||||
});
|
||||
|
||||
return Results.Ok(result.Result);
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomVersions")
|
||||
.WithDescription("Returns the paginated version timeline for a specific artifact, listing SBOM snapshots in chronological order. Requires artifact query parameter. Limit must be between 1 and 200.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
var sbomUploadHandler = async Task<IResult> (
|
||||
[FromBody] SbomUploadRequest request,
|
||||
@@ -577,8 +610,14 @@ var sbomUploadHandler = async Task<IResult> (
|
||||
return Results.Accepted($"/sbom/ledger/history?artifact={Uri.EscapeDataString(response.ArtifactRef)}", response);
|
||||
};
|
||||
|
||||
app.MapPost("/sbom/upload", sbomUploadHandler);
|
||||
app.MapPost("/api/v1/sbom/upload", sbomUploadHandler);
|
||||
app.MapPost("/sbom/upload", sbomUploadHandler)
|
||||
.WithName("UploadSbom")
|
||||
.WithDescription("Uploads and ingests a new SBOM for the specified artifact, validating the payload and persisting it to the ledger. Returns 202 Accepted with the artifact reference and ledger entry on success. Returns 400 if validation fails.")
|
||||
.RequireAuthorization(SbomPolicies.Write);
|
||||
app.MapPost("/api/v1/sbom/upload", sbomUploadHandler)
|
||||
.WithName("UploadSbomV1")
|
||||
.WithDescription("Canonical v1 API path alias for UploadSbom. Uploads and ingests a new SBOM for the specified artifact, validating the payload and persisting it to the ledger. Returns 202 Accepted with the artifact reference and ledger entry on success.")
|
||||
.RequireAuthorization(SbomPolicies.Write);
|
||||
|
||||
app.MapGet("/sbom/ledger/history", async Task<IResult> (
|
||||
[FromServices] ISbomLedgerService ledgerService,
|
||||
@@ -606,7 +645,10 @@ app.MapGet("/sbom/ledger/history", async Task<IResult> (
|
||||
}
|
||||
|
||||
return Results.Ok(history);
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomLedgerHistory")
|
||||
.WithDescription("Returns the paginated ledger history for a specific artifact, listing SBOM versions in chronological order with ledger metadata. Requires artifact query parameter. Returns 404 if no history is found.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/sbom/ledger/point", async Task<IResult> (
|
||||
[FromServices] ISbomLedgerService ledgerService,
|
||||
@@ -631,7 +673,10 @@ app.MapGet("/sbom/ledger/point", async Task<IResult> (
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomLedgerPoint")
|
||||
.WithDescription("Returns the SBOM ledger entry for a specific artifact at a given point in time. Requires artifact and at (ISO-8601 timestamp) query parameters. Returns 404 if no ledger entry exists for the specified time.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/sbom/ledger/range", async Task<IResult> (
|
||||
[FromServices] ISbomLedgerService ledgerService,
|
||||
@@ -671,7 +716,10 @@ app.MapGet("/sbom/ledger/range", async Task<IResult> (
|
||||
}
|
||||
|
||||
return Results.Ok(history);
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomLedgerRange")
|
||||
.WithDescription("Returns paginated SBOM ledger entries for a specific artifact within a time range defined by start and end ISO-8601 timestamps. Requires artifact, start, and end query parameters. Returns 404 if no data is found for the range.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/sbom/ledger/diff", async Task<IResult> (
|
||||
[FromServices] ISbomLedgerService ledgerService,
|
||||
@@ -692,7 +740,10 @@ app.MapGet("/sbom/ledger/diff", async Task<IResult> (
|
||||
|
||||
SbomMetrics.LedgerDiffsTotal.Add(1);
|
||||
return Results.Ok(diff);
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomLedgerDiff")
|
||||
.WithDescription("Returns a component-level diff between two SBOM ledger entries identified by their GUIDs (before and after). Highlights added, removed, and changed components between two SBOM versions. Returns 404 if either entry is not found.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/sbom/ledger/lineage", async Task<IResult> (
|
||||
[FromServices] ISbomLedgerService ledgerService,
|
||||
@@ -711,7 +762,10 @@ app.MapGet("/sbom/ledger/lineage", async Task<IResult> (
|
||||
}
|
||||
|
||||
return Results.Ok(lineage);
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomLedgerLineage")
|
||||
.WithDescription("Returns the full artifact lineage chain from the SBOM ledger for a specific artifact, showing the provenance ancestry of SBOM versions. Requires artifact query parameter. Returns 404 if lineage is not found.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Lineage Graph API Endpoints (LIN-BE-013/014)
|
||||
@@ -760,7 +814,10 @@ app.MapGet("/api/v1/lineage/{artifactDigest}", async Task<IResult> (
|
||||
}
|
||||
|
||||
return Results.Ok(graph);
|
||||
});
|
||||
})
|
||||
.WithName("GetLineageGraph")
|
||||
.WithDescription("Returns the lineage graph for a specific artifact by digest for the given tenant, including upstream provenance nodes up to maxDepth levels, optional trust badges, and an optional deterministic replay hash. Returns 404 if the graph is not found.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/api/v1/lineage/diff", async Task<IResult> (
|
||||
[FromServices] ISbomLineageGraphService lineageService,
|
||||
@@ -797,7 +854,10 @@ app.MapGet("/api/v1/lineage/diff", async Task<IResult> (
|
||||
|
||||
SbomMetrics.LedgerDiffsTotal.Add(1);
|
||||
return Results.Ok(diff);
|
||||
});
|
||||
})
|
||||
.WithName("GetLineageDiff")
|
||||
.WithDescription("Returns a graph-level diff between two artifact lineage graphs identified by their digests (from and to) for the given tenant. Highlights added and removed nodes and edges between two artifact versions. Returns 404 if either graph is not found.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/api/v1/lineage/hover", async Task<IResult> (
|
||||
[FromServices] ISbomLineageGraphService lineageService,
|
||||
@@ -831,7 +891,10 @@ app.MapGet("/api/v1/lineage/hover", async Task<IResult> (
|
||||
}
|
||||
|
||||
return Results.Ok(hoverCard);
|
||||
});
|
||||
})
|
||||
.WithName("GetLineageHoverCard")
|
||||
.WithDescription("Returns a lightweight hover card summary of the lineage relationship between two artifact digests for the given tenant. Used for fast UI hover popups. Cached for low-latency responses. Returns 404 if no hover card data is available.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/api/v1/lineage/{artifactDigest}/children", async Task<IResult> (
|
||||
[FromServices] ISbomLineageGraphService lineageService,
|
||||
@@ -859,7 +922,10 @@ app.MapGet("/api/v1/lineage/{artifactDigest}/children", async Task<IResult> (
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new { parentDigest = artifactDigest.Trim(), children });
|
||||
});
|
||||
})
|
||||
.WithName("GetLineageChildren")
|
||||
.WithDescription("Returns the direct child artifacts in the lineage graph for a specific artifact digest and tenant. Lists artifacts that were built from or derived from the specified artifact.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/api/v1/lineage/{artifactDigest}/parents", async Task<IResult> (
|
||||
[FromServices] ISbomLineageGraphService lineageService,
|
||||
@@ -887,7 +953,10 @@ app.MapGet("/api/v1/lineage/{artifactDigest}/parents", async Task<IResult> (
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new { childDigest = artifactDigest.Trim(), parents });
|
||||
});
|
||||
})
|
||||
.WithName("GetLineageParents")
|
||||
.WithDescription("Returns the direct parent artifacts in the lineage graph for a specific artifact digest and tenant. Lists artifacts from which the specified artifact was built or derived.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapPost("/api/v1/lineage/export", async Task<IResult> (
|
||||
[FromServices] ILineageExportService exportService,
|
||||
@@ -922,7 +991,10 @@ app.MapPost("/api/v1/lineage/export", async Task<IResult> (
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
});
|
||||
})
|
||||
.WithName("ExportLineage")
|
||||
.WithDescription("Exports the lineage evidence pack between two artifact digests for the given tenant as a structured bundle. Enforces a 50 MB size limit on the export payload. Returns 413 if the export exceeds the size limit. Requires fromDigest, toDigest, and tenantId in the request body.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Lineage Compare API (LIN-BE-028)
|
||||
@@ -978,7 +1050,10 @@ app.MapGet("/api/v1/lineage/compare", async Task<IResult> (
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
});
|
||||
})
|
||||
.WithName("CompareLineage")
|
||||
.WithDescription("Returns a rich comparison between two artifact versions by lineage digest (a and b) for the given tenant. Optionally includes SBOM diff, VEX deltas, reachability deltas, attestations, and replay hashes. Returns 404 if comparison data is not found.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Replay Verification API (LIN-BE-033)
|
||||
@@ -1020,7 +1095,10 @@ app.MapPost("/api/v1/lineage/verify", async Task<IResult> (
|
||||
var result = await verificationService.VerifyAsync(verifyRequest, cancellationToken);
|
||||
|
||||
return Results.Ok(result);
|
||||
});
|
||||
})
|
||||
.WithName("VerifyLineageReplay")
|
||||
.WithDescription("Verifies a deterministic replay hash against the current policy and SBOM state to confirm the release decision is reproducible. Optionally re-evaluates the policy against current feeds. Requires replayHash and tenantId in the request body.")
|
||||
.RequireAuthorization(SbomPolicies.Write);
|
||||
|
||||
app.MapPost("/api/v1/lineage/compare-drift", async Task<IResult> (
|
||||
[FromServices] IReplayVerificationService verificationService,
|
||||
@@ -1047,7 +1125,10 @@ app.MapPost("/api/v1/lineage/compare-drift", async Task<IResult> (
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(result);
|
||||
});
|
||||
})
|
||||
.WithName("CompareLineageDrift")
|
||||
.WithDescription("Compares two replay hashes (hashA and hashB) for the given tenant to detect drift between two release decision points. Returns a structured drift report indicating whether the two points are equivalent. Requires hashA, hashB, and tenantId.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
@@ -1105,7 +1186,10 @@ app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
|
||||
app.Logger.LogInformation("projection_returned tenant={Tenant} snapshot={Snapshot} size={SizeBytes}", projection.TenantId, projection.SnapshotId, sizeBytes);
|
||||
|
||||
return Results.Ok(payload);
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomProjection")
|
||||
.WithDescription("Returns the structured SBOM projection for a specific snapshot ID and tenant. The projection contains the full normalized component graph with schema version and a deterministic hash. Used by the policy engine and reachability graph for decision-making.")
|
||||
.RequireAuthorization(SbomPolicies.Read);
|
||||
|
||||
app.MapGet("/internal/sbom/events", async Task<IResult> (
|
||||
[FromServices] ISbomEventStore store,
|
||||
@@ -1120,7 +1204,10 @@ app.MapGet("/internal/sbom/events", async Task<IResult> (
|
||||
app.Logger.LogWarning("sbom event backlog high: {Count}", events.Count);
|
||||
}
|
||||
return Results.Ok(events);
|
||||
});
|
||||
})
|
||||
.WithName("ListSbomEvents")
|
||||
.WithDescription("Internal endpoint. Returns all SBOM version-created events in the in-memory event store backlog. Logs a warning if the backlog exceeds 100 entries. Used by orchestrators to process pending SBOM ingestion events.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapGet("/internal/sbom/asset-events", async Task<IResult> (
|
||||
[FromServices] ISbomEventStore store,
|
||||
@@ -1136,7 +1223,10 @@ app.MapGet("/internal/sbom/asset-events", async Task<IResult> (
|
||||
}
|
||||
|
||||
return Results.Ok(events);
|
||||
});
|
||||
})
|
||||
.WithName("ListSbomAssetEvents")
|
||||
.WithDescription("Internal endpoint. Returns all SBOM asset-level events from the in-memory event store. Used by orchestrators to process asset lifecycle changes associated with SBOM versions.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapGet("/internal/sbom/ledger/audit", async Task<IResult> (
|
||||
[FromServices] ISbomLedgerService ledgerService,
|
||||
@@ -1150,7 +1240,10 @@ app.MapGet("/internal/sbom/ledger/audit", async Task<IResult> (
|
||||
|
||||
var audit = await ledgerService.GetAuditAsync(artifact.Trim(), cancellationToken);
|
||||
return Results.Ok(audit.OrderBy(a => a.TimestampUtc).ToList());
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomLedgerAudit")
|
||||
.WithDescription("Internal endpoint. Returns the chronologically ordered audit trail for a specific artifact from the SBOM ledger, listing all state transitions and operations. Requires artifact query parameter.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapGet("/internal/sbom/analysis/jobs", async Task<IResult> (
|
||||
[FromServices] ISbomLedgerService ledgerService,
|
||||
@@ -1164,7 +1257,10 @@ app.MapGet("/internal/sbom/analysis/jobs", async Task<IResult> (
|
||||
|
||||
var jobs = await ledgerService.ListAnalysisJobsAsync(artifact.Trim(), cancellationToken);
|
||||
return Results.Ok(jobs.OrderBy(j => j.CreatedAtUtc).ToList());
|
||||
});
|
||||
})
|
||||
.WithName("ListSbomAnalysisJobs")
|
||||
.WithDescription("Internal endpoint. Returns the chronologically ordered list of SBOM analysis jobs for a specific artifact. Requires artifact query parameter.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapPost("/internal/sbom/events/backfill", async Task<IResult> (
|
||||
[FromServices] IProjectionRepository repository,
|
||||
@@ -1194,7 +1290,10 @@ app.MapPost("/internal/sbom/events/backfill", async Task<IResult> (
|
||||
app.Logger.LogInformation("sbom events backfilled={Count}", published);
|
||||
}
|
||||
return Results.Ok(new { published });
|
||||
});
|
||||
})
|
||||
.WithName("BackfillSbomEvents")
|
||||
.WithDescription("Internal endpoint. Replays all known SBOM projections as version-created events into the event store backlog. Used for backfill and recovery scenarios after store resets. Returns the count of successfully published events.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapGet("/internal/sbom/inventory", async Task<IResult> (
|
||||
[FromServices] ISbomEventStore store,
|
||||
@@ -1203,7 +1302,10 @@ app.MapGet("/internal/sbom/inventory", async Task<IResult> (
|
||||
using var activity = SbomTracing.Source.StartActivity("inventory.list", ActivityKind.Server);
|
||||
var items = await store.ListInventoryAsync(cancellationToken);
|
||||
return Results.Ok(items);
|
||||
});
|
||||
})
|
||||
.WithName("ListSbomInventory")
|
||||
.WithDescription("Internal endpoint. Returns all SBOM inventory entries from the event store, representing the known set of artifacts and their SBOM state across tenants.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapPost("/internal/sbom/inventory/backfill", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
@@ -1220,7 +1322,10 @@ app.MapPost("/internal/sbom/inventory/backfill", async Task<IResult> (
|
||||
published++;
|
||||
}
|
||||
return Results.Ok(new { published });
|
||||
});
|
||||
})
|
||||
.WithName("BackfillSbomInventory")
|
||||
.WithDescription("Internal endpoint. Clears and replays the SBOM inventory by re-fetching projections for known snapshot/tenant pairs. Used for recovery after inventory store resets. Returns the count of replayed entries.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapGet("/internal/sbom/resolver-feed", async Task<IResult> (
|
||||
[FromServices] ISbomEventStore store,
|
||||
@@ -1228,7 +1333,10 @@ app.MapGet("/internal/sbom/resolver-feed", async Task<IResult> (
|
||||
{
|
||||
var feed = await store.ListResolverAsync(cancellationToken);
|
||||
return Results.Ok(feed);
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomResolverFeed")
|
||||
.WithDescription("Internal endpoint. Returns all resolver feed candidates from the event store. The resolver feed is used by the policy engine and scanner to resolve component identities across SBOM versions.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapPost("/internal/sbom/resolver-feed/backfill", async Task<IResult> (
|
||||
[FromServices] ISbomEventStore store,
|
||||
@@ -1243,7 +1351,10 @@ app.MapPost("/internal/sbom/resolver-feed/backfill", async Task<IResult> (
|
||||
}
|
||||
var feed = await store.ListResolverAsync(cancellationToken);
|
||||
return Results.Ok(new { published = feed.Count });
|
||||
});
|
||||
})
|
||||
.WithName("BackfillSbomResolverFeed")
|
||||
.WithDescription("Internal endpoint. Clears and replays the resolver feed by re-fetching projections for known snapshot/tenant pairs. Used for recovery after resolver store resets. Returns the count of re-published resolver feed entries.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapGet("/internal/sbom/resolver-feed/export", async Task<IResult> (
|
||||
[FromServices] ISbomEventStore store,
|
||||
@@ -1253,7 +1364,10 @@ app.MapGet("/internal/sbom/resolver-feed/export", async Task<IResult> (
|
||||
var lines = feed.Select(candidate => JsonSerializer.Serialize(candidate));
|
||||
var ndjson = string.Join('\n', lines);
|
||||
return Results.Text(ndjson, "application/x-ndjson");
|
||||
});
|
||||
})
|
||||
.WithName("ExportSbomResolverFeed")
|
||||
.WithDescription("Internal endpoint. Exports all resolver feed candidates as a newline-delimited JSON (NDJSON) stream. Used for bulk export and offline processing of the resolver feed by external consumers.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapPost("/internal/sbom/retention/prune", async Task<IResult> (
|
||||
[FromServices] ISbomLedgerService ledgerService,
|
||||
@@ -1266,7 +1380,10 @@ app.MapPost("/internal/sbom/retention/prune", async Task<IResult> (
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
});
|
||||
})
|
||||
.WithName("PruneSbomRetention")
|
||||
.WithDescription("Internal endpoint. Applies the configured retention policy to the SBOM ledger, pruning old versions beyond the configured min/max version counts. Records pruned version counts in metrics. Returns a retention result summary.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapGet("/internal/orchestrator/sources", async Task<IResult> (
|
||||
[FromQuery] string? tenant,
|
||||
@@ -1280,7 +1397,10 @@ app.MapGet("/internal/orchestrator/sources", async Task<IResult> (
|
||||
|
||||
var sources = await repository.ListAsync(tenant.Trim(), cancellationToken);
|
||||
return Results.Ok(new { tenant = tenant.Trim(), items = sources });
|
||||
});
|
||||
})
|
||||
.WithName("ListOrchestratorSources")
|
||||
.WithDescription("Internal endpoint. Returns all registered orchestrator artifact sources for the given tenant. Requires tenant query parameter.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapPost("/internal/orchestrator/sources", async Task<IResult> (
|
||||
RegisterOrchestratorSourceRequest request,
|
||||
@@ -1302,7 +1422,10 @@ app.MapPost("/internal/orchestrator/sources", async Task<IResult> (
|
||||
|
||||
var source = await repository.RegisterAsync(request, cancellationToken);
|
||||
return Results.Ok(source);
|
||||
});
|
||||
})
|
||||
.WithName("RegisterOrchestratorSource")
|
||||
.WithDescription("Internal endpoint. Registers a new orchestrator artifact source for the given tenant linking an artifact digest to a source type. Requires tenantId, artifactDigest, and sourceType in the request body.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapGet("/internal/orchestrator/control", async Task<IResult> (
|
||||
[FromQuery] string? tenant,
|
||||
@@ -1316,7 +1439,10 @@ app.MapGet("/internal/orchestrator/control", async Task<IResult> (
|
||||
|
||||
var state = await service.GetAsync(tenant.Trim(), cancellationToken);
|
||||
return Results.Ok(state);
|
||||
});
|
||||
})
|
||||
.WithName("GetOrchestratorControl")
|
||||
.WithDescription("Internal endpoint. Returns the current orchestrator control state for the given tenant including pause/resume flags and scheduling overrides. Requires tenant query parameter.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapPost("/internal/orchestrator/control", async Task<IResult> (
|
||||
OrchestratorControlRequest request,
|
||||
@@ -1330,7 +1456,10 @@ app.MapPost("/internal/orchestrator/control", async Task<IResult> (
|
||||
|
||||
var updated = await service.UpdateAsync(request, cancellationToken);
|
||||
return Results.Ok(updated);
|
||||
});
|
||||
})
|
||||
.WithName("UpdateOrchestratorControl")
|
||||
.WithDescription("Internal endpoint. Updates the orchestrator control state for the given tenant, allowing operators to pause, resume, or adjust scheduling parameters. Requires tenantId in the request body.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapGet("/internal/orchestrator/watermarks", async Task<IResult> (
|
||||
[FromQuery] string? tenant,
|
||||
@@ -1344,7 +1473,10 @@ app.MapGet("/internal/orchestrator/watermarks", async Task<IResult> (
|
||||
|
||||
var state = await service.GetAsync(tenant.Trim(), cancellationToken);
|
||||
return Results.Ok(state);
|
||||
});
|
||||
})
|
||||
.WithName("GetOrchestratorWatermarks")
|
||||
.WithDescription("Internal endpoint. Returns the current ingestion watermark state for the given tenant, indicating the last successfully processed position in the artifact stream. Requires tenant query parameter.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.MapPost("/internal/orchestrator/watermarks", async Task<IResult> (
|
||||
[FromQuery] string? tenant,
|
||||
@@ -1359,7 +1491,10 @@ app.MapPost("/internal/orchestrator/watermarks", async Task<IResult> (
|
||||
|
||||
var updated = await service.SetAsync(tenant.Trim(), watermark ?? string.Empty, cancellationToken);
|
||||
return Results.Ok(updated);
|
||||
});
|
||||
})
|
||||
.WithName("SetOrchestratorWatermark")
|
||||
.WithDescription("Internal endpoint. Sets the ingestion watermark for the given tenant to the specified value, marking the last processed position in the artifact stream. Requires tenant query parameter.")
|
||||
.RequireAuthorization(SbomPolicies.Internal);
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using StellaOps.SbomService.Lineage.EfCore.CompiledModels;
|
||||
using StellaOps.SbomService.Lineage.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
[assembly: DbContextModel(typeof(LineageDbContext), typeof(LineageDbContextModel))]
|
||||
@@ -0,0 +1,48 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using StellaOps.SbomService.Lineage.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.EfCore.CompiledModels
|
||||
{
|
||||
[DbContext(typeof(LineageDbContext))]
|
||||
public partial class LineageDbContextModel : RuntimeModel
|
||||
{
|
||||
private static readonly bool _useOldBehavior31751 =
|
||||
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
|
||||
|
||||
static LineageDbContextModel()
|
||||
{
|
||||
var model = new LineageDbContextModel();
|
||||
|
||||
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 = (LineageDbContextModel)model.FinalizeModel();
|
||||
}
|
||||
|
||||
private static LineageDbContextModel _instance;
|
||||
public static IModel Instance => _instance;
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.EfCore.CompiledModels
|
||||
{
|
||||
public partial class LineageDbContextModel
|
||||
{
|
||||
private LineageDbContextModel()
|
||||
: base(skipDetectChanges: false, modelId: new Guid("a2c14f5e-8b3d-4e9a-b1f7-6d0e3c8a5b2f"), entityTypeCount: 3)
|
||||
{
|
||||
}
|
||||
|
||||
partial void Initialize()
|
||||
{
|
||||
var sbomLineageEdge = SbomLineageEdgeEntityType.Create(this);
|
||||
var vexDeltaEntity = VexDeltaEntityEntityType.Create(this);
|
||||
var sbomVerdictLinkEntity = SbomVerdictLinkEntityEntityType.Create(this);
|
||||
|
||||
SbomLineageEdgeEntityType.CreateAnnotations(sbomLineageEdge);
|
||||
VexDeltaEntityEntityType.CreateAnnotations(vexDeltaEntity);
|
||||
SbomVerdictLinkEntityEntityType.CreateAnnotations(sbomVerdictLinkEntity);
|
||||
|
||||
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
|
||||
AddAnnotation("ProductVersion", "10.0.0");
|
||||
AddAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.SbomService.Lineage.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.EfCore.CompiledModels
|
||||
{
|
||||
internal partial class SbomLineageEdgeEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.SbomService.Lineage.EfCore.Models.SbomLineageEdge",
|
||||
typeof(SbomLineageEdge),
|
||||
baseEntityType);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(SbomLineageEdge).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null,
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var parentDigest = runtimeEntityType.AddProperty(
|
||||
"ParentDigest",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(SbomLineageEdge).GetProperty("ParentDigest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
parentDigest.AddAnnotation("Relational:ColumnName", "parent_digest");
|
||||
|
||||
var childDigest = runtimeEntityType.AddProperty(
|
||||
"ChildDigest",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(SbomLineageEdge).GetProperty("ChildDigest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
childDigest.AddAnnotation("Relational:ColumnName", "child_digest");
|
||||
|
||||
var relationship = runtimeEntityType.AddProperty(
|
||||
"Relationship",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(SbomLineageEdge).GetProperty("Relationship", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
relationship.AddAnnotation("Relational:ColumnName", "relationship");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(SbomLineageEdge).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(SbomLineageEdge).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var key = runtimeEntityType.AddKey(new[] { id });
|
||||
key.AddAnnotation("Relational:Name", "sbom_lineage_edges_pkey");
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
|
||||
var uqIndex = runtimeEntityType.AddIndex(new[] { parentDigest, childDigest, tenantId }, "uq_lineage_edge", unique: true);
|
||||
runtimeEntityType.AddIndex(new[] { parentDigest, tenantId }, "idx_lineage_edges_parent");
|
||||
runtimeEntityType.AddIndex(new[] { childDigest, tenantId }, "idx_lineage_edges_child");
|
||||
runtimeEntityType.AddIndex(new[] { tenantId, createdAt }, "idx_lineage_edges_created");
|
||||
runtimeEntityType.AddIndex(new[] { relationship, tenantId }, "idx_lineage_edges_relationship");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "sbom");
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "sbom_lineage_edges");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.SbomService.Lineage.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.EfCore.CompiledModels
|
||||
{
|
||||
internal partial class SbomVerdictLinkEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.SbomService.Lineage.EfCore.Models.SbomVerdictLinkEntity",
|
||||
typeof(SbomVerdictLinkEntity),
|
||||
baseEntityType);
|
||||
|
||||
var sbomVersionId = runtimeEntityType.AddProperty(
|
||||
"SbomVersionId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(SbomVerdictLinkEntity).GetProperty("SbomVersionId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
sbomVersionId.AddAnnotation("Relational:ColumnName", "sbom_version_id");
|
||||
|
||||
var cve = runtimeEntityType.AddProperty(
|
||||
"Cve",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(SbomVerdictLinkEntity).GetProperty("Cve", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
cve.AddAnnotation("Relational:ColumnName", "cve");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(SbomVerdictLinkEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var consensusProjectionId = runtimeEntityType.AddProperty(
|
||||
"ConsensusProjectionId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(SbomVerdictLinkEntity).GetProperty("ConsensusProjectionId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
consensusProjectionId.AddAnnotation("Relational:ColumnName", "consensus_projection_id");
|
||||
|
||||
var verdictStatus = runtimeEntityType.AddProperty(
|
||||
"VerdictStatus",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(SbomVerdictLinkEntity).GetProperty("VerdictStatus", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
verdictStatus.AddAnnotation("Relational:ColumnName", "verdict_status");
|
||||
|
||||
var confidenceScore = runtimeEntityType.AddProperty(
|
||||
"ConfidenceScore",
|
||||
typeof(decimal),
|
||||
propertyInfo: typeof(SbomVerdictLinkEntity).GetProperty("ConfidenceScore", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
confidenceScore.AddAnnotation("Relational:ColumnName", "confidence_score");
|
||||
confidenceScore.AddAnnotation("Relational:ColumnType", "decimal(5,4)");
|
||||
|
||||
var linkedAt = runtimeEntityType.AddProperty(
|
||||
"LinkedAt",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(SbomVerdictLinkEntity).GetProperty("LinkedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
linkedAt.AddAnnotation("Relational:ColumnName", "linked_at");
|
||||
linkedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var key = runtimeEntityType.AddKey(new[] { sbomVersionId, cve, tenantId });
|
||||
key.AddAnnotation("Relational:Name", "sbom_verdict_links_pkey");
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
|
||||
runtimeEntityType.AddIndex(new[] { cve, tenantId }, "idx_verdict_links_cve");
|
||||
runtimeEntityType.AddIndex(new[] { consensusProjectionId }, "idx_verdict_links_projection");
|
||||
runtimeEntityType.AddIndex(new[] { sbomVersionId, tenantId }, "idx_verdict_links_sbom_version");
|
||||
runtimeEntityType.AddIndex(new[] { verdictStatus, tenantId }, "idx_verdict_links_status");
|
||||
runtimeEntityType.AddIndex(new[] { tenantId, confidenceScore }, "idx_verdict_links_confidence");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "sbom");
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "sbom_verdict_links");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.SbomService.Lineage.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.EfCore.CompiledModels
|
||||
{
|
||||
internal partial class VexDeltaEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.SbomService.Lineage.EfCore.Models.VexDeltaEntity",
|
||||
typeof(VexDeltaEntity),
|
||||
baseEntityType);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(VexDeltaEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null,
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(VexDeltaEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var fromArtifactDigest = runtimeEntityType.AddProperty(
|
||||
"FromArtifactDigest",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VexDeltaEntity).GetProperty("FromArtifactDigest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
fromArtifactDigest.AddAnnotation("Relational:ColumnName", "from_artifact_digest");
|
||||
|
||||
var toArtifactDigest = runtimeEntityType.AddProperty(
|
||||
"ToArtifactDigest",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VexDeltaEntity).GetProperty("ToArtifactDigest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
toArtifactDigest.AddAnnotation("Relational:ColumnName", "to_artifact_digest");
|
||||
|
||||
var cve = runtimeEntityType.AddProperty(
|
||||
"Cve",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VexDeltaEntity).GetProperty("Cve", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
cve.AddAnnotation("Relational:ColumnName", "cve");
|
||||
|
||||
var fromStatus = runtimeEntityType.AddProperty(
|
||||
"FromStatus",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VexDeltaEntity).GetProperty("FromStatus", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
fromStatus.AddAnnotation("Relational:ColumnName", "from_status");
|
||||
|
||||
var toStatus = runtimeEntityType.AddProperty(
|
||||
"ToStatus",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VexDeltaEntity).GetProperty("ToStatus", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
toStatus.AddAnnotation("Relational:ColumnName", "to_status");
|
||||
|
||||
var rationale = runtimeEntityType.AddProperty(
|
||||
"Rationale",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VexDeltaEntity).GetProperty("Rationale", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
rationale.AddAnnotation("Relational:ColumnName", "rationale");
|
||||
rationale.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
rationale.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var replayHash = runtimeEntityType.AddProperty(
|
||||
"ReplayHash",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VexDeltaEntity).GetProperty("ReplayHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
replayHash.AddAnnotation("Relational:ColumnName", "replay_hash");
|
||||
|
||||
var attestationDigest = runtimeEntityType.AddProperty(
|
||||
"AttestationDigest",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VexDeltaEntity).GetProperty("AttestationDigest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null,
|
||||
nullable: true);
|
||||
attestationDigest.AddAnnotation("Relational:ColumnName", "attestation_digest");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(VexDeltaEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var key = runtimeEntityType.AddKey(new[] { id });
|
||||
key.AddAnnotation("Relational:Name", "vex_deltas_pkey");
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
|
||||
runtimeEntityType.AddIndex(new[] { tenantId, fromArtifactDigest, toArtifactDigest, cve }, "uq_vex_delta", unique: true);
|
||||
runtimeEntityType.AddIndex(new[] { toArtifactDigest, tenantId }, "idx_vex_deltas_to");
|
||||
runtimeEntityType.AddIndex(new[] { fromArtifactDigest, tenantId }, "idx_vex_deltas_from");
|
||||
runtimeEntityType.AddIndex(new[] { cve, tenantId }, "idx_vex_deltas_cve");
|
||||
runtimeEntityType.AddIndex(new[] { tenantId, createdAt }, "idx_vex_deltas_created");
|
||||
runtimeEntityType.AddIndex(new[] { tenantId, fromStatus, toStatus }, "idx_vex_deltas_status_change");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "vex");
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "vex_deltas");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.EfCore.Context;
|
||||
|
||||
public partial class LineageDbContext
|
||||
{
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
|
||||
{
|
||||
// No navigation properties or enum overlays required for lineage entities.
|
||||
// Relationship types (parent/build/base) and VEX statuses are stored as
|
||||
// CHECK-constrained text columns and mapped in the domain layer.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.SbomService.Lineage.EfCore.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.EfCore.Context;
|
||||
|
||||
public partial class LineageDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public LineageDbContext(DbContextOptions<LineageDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "sbom"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<SbomLineageEdge> SbomLineageEdges { get; set; }
|
||||
|
||||
public virtual DbSet<VexDeltaEntity> VexDeltas { get; set; }
|
||||
|
||||
public virtual DbSet<SbomVerdictLinkEntity> SbomVerdictLinks { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// Determine vex schema: if using non-default schema for tests, use the same schema;
|
||||
// otherwise use "vex" as defined by the SQL migration.
|
||||
var vexSchemaName = string.Equals(schemaName, "sbom", StringComparison.Ordinal) ? "vex" : schemaName;
|
||||
|
||||
modelBuilder.Entity<SbomLineageEdge>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("sbom_lineage_edges_pkey");
|
||||
|
||||
entity.ToTable("sbom_lineage_edges", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.ParentDigest, e.ChildDigest, e.TenantId }, "uq_lineage_edge")
|
||||
.IsUnique();
|
||||
|
||||
entity.HasIndex(e => new { e.ParentDigest, e.TenantId }, "idx_lineage_edges_parent");
|
||||
entity.HasIndex(e => new { e.ChildDigest, e.TenantId }, "idx_lineage_edges_child");
|
||||
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_lineage_edges_created")
|
||||
.IsDescending(false, true);
|
||||
entity.HasIndex(e => new { e.Relationship, e.TenantId }, "idx_lineage_edges_relationship");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.ParentDigest).HasColumnName("parent_digest");
|
||||
entity.Property(e => e.ChildDigest).HasColumnName("child_digest");
|
||||
entity.Property(e => e.Relationship).HasColumnName("relationship");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<VexDeltaEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("vex_deltas_pkey");
|
||||
|
||||
entity.ToTable("vex_deltas", vexSchemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.FromArtifactDigest, e.ToArtifactDigest, e.Cve }, "uq_vex_delta")
|
||||
.IsUnique();
|
||||
|
||||
entity.HasIndex(e => new { e.ToArtifactDigest, e.TenantId }, "idx_vex_deltas_to");
|
||||
entity.HasIndex(e => new { e.FromArtifactDigest, e.TenantId }, "idx_vex_deltas_from");
|
||||
entity.HasIndex(e => new { e.Cve, e.TenantId }, "idx_vex_deltas_cve");
|
||||
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_vex_deltas_created")
|
||||
.IsDescending(false, true);
|
||||
entity.HasIndex(e => new { e.TenantId, e.FromStatus, e.ToStatus }, "idx_vex_deltas_status_change");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.FromArtifactDigest).HasColumnName("from_artifact_digest");
|
||||
entity.Property(e => e.ToArtifactDigest).HasColumnName("to_artifact_digest");
|
||||
entity.Property(e => e.Cve).HasColumnName("cve");
|
||||
entity.Property(e => e.FromStatus).HasColumnName("from_status");
|
||||
entity.Property(e => e.ToStatus).HasColumnName("to_status");
|
||||
entity.Property(e => e.Rationale)
|
||||
.HasDefaultValueSql("'{}'::jsonb")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("rationale");
|
||||
entity.Property(e => e.ReplayHash).HasColumnName("replay_hash");
|
||||
entity.Property(e => e.AttestationDigest).HasColumnName("attestation_digest");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SbomVerdictLinkEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.SbomVersionId, e.Cve, e.TenantId })
|
||||
.HasName("sbom_verdict_links_pkey");
|
||||
|
||||
entity.ToTable("sbom_verdict_links", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.Cve, e.TenantId }, "idx_verdict_links_cve");
|
||||
entity.HasIndex(e => e.ConsensusProjectionId, "idx_verdict_links_projection");
|
||||
entity.HasIndex(e => new { e.SbomVersionId, e.TenantId }, "idx_verdict_links_sbom_version");
|
||||
entity.HasIndex(e => new { e.VerdictStatus, e.TenantId }, "idx_verdict_links_status");
|
||||
entity.HasIndex(e => new { e.TenantId, e.ConfidenceScore }, "idx_verdict_links_confidence")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.SbomVersionId).HasColumnName("sbom_version_id");
|
||||
entity.Property(e => e.Cve).HasColumnName("cve");
|
||||
entity.Property(e => e.ConsensusProjectionId).HasColumnName("consensus_projection_id");
|
||||
entity.Property(e => e.VerdictStatus).HasColumnName("verdict_status");
|
||||
entity.Property(e => e.ConfidenceScore)
|
||||
.HasColumnType("decimal(5,4)")
|
||||
.HasColumnName("confidence_score");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.LinkedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("linked_at");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.EfCore.Context;
|
||||
|
||||
public sealed class LineageDesignTimeDbContextFactory : IDesignTimeDbContextFactory<LineageDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=sbom,vex,public";
|
||||
private const string ConnectionStringEnvironmentVariable =
|
||||
"STELLAOPS_SBOMLINEAGE_EF_CONNECTION";
|
||||
|
||||
public LineageDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<LineageDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new LineageDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.SbomService.Lineage.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for sbom.sbom_lineage_edges table.
|
||||
/// </summary>
|
||||
public partial class SbomLineageEdge
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string ParentDigest { get; set; } = null!;
|
||||
|
||||
public string ChildDigest { get; set; } = null!;
|
||||
|
||||
public string Relationship { get; set; } = null!;
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StellaOps.SbomService.Lineage.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for sbom.sbom_verdict_links table.
|
||||
/// </summary>
|
||||
public partial class SbomVerdictLinkEntity
|
||||
{
|
||||
public Guid SbomVersionId { get; set; }
|
||||
|
||||
public string Cve { get; set; } = null!;
|
||||
|
||||
public Guid ConsensusProjectionId { get; set; }
|
||||
|
||||
public string VerdictStatus { get; set; } = null!;
|
||||
|
||||
public decimal ConfidenceScore { get; set; }
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
public DateTime LinkedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace StellaOps.SbomService.Lineage.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for vex.vex_deltas table.
|
||||
/// </summary>
|
||||
public partial class VexDeltaEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
public string FromArtifactDigest { get; set; } = null!;
|
||||
|
||||
public string ToArtifactDigest { get; set; } = null!;
|
||||
|
||||
public string Cve { get; set; } = null!;
|
||||
|
||||
public string FromStatus { get; set; } = null!;
|
||||
|
||||
public string ToStatus { get; set; } = null!;
|
||||
|
||||
public string Rationale { get; set; } = null!;
|
||||
|
||||
public string ReplayHash { get; set; } = null!;
|
||||
|
||||
public string? AttestationDigest { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.SbomService.Lineage.EfCore.CompiledModels;
|
||||
using StellaOps.SbomService.Lineage.EfCore.Context;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Persistence;
|
||||
|
||||
internal static class LineageDbContextFactory
|
||||
{
|
||||
public static LineageDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? LineageDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<LineageDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, LineageDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(LineageDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new LineageDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
using StellaOps.SbomService.Lineage.EfCore.Models;
|
||||
using StellaOps.SbomService.Lineage.Persistence;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of SBOM lineage edge repository.
|
||||
/// EF Core implementation of SBOM lineage edge repository.
|
||||
/// </summary>
|
||||
public sealed class SbomLineageEdgeRepository : RepositoryBase<LineageDataSource>, ISbomLineageEdgeRepository
|
||||
{
|
||||
private const string Schema = "sbom";
|
||||
private const string Table = "sbom_lineage_edges";
|
||||
private const string FullTable = $"{Schema}.{Table}";
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SbomLineageEdgeRepository(
|
||||
@@ -92,23 +92,18 @@ public sealed class SbomLineageEdgeRepository : RepositoryBase<LineageDataSource
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT id, parent_digest, child_digest, relationship, tenant_id, created_at
|
||||
FROM {FullTable}
|
||||
WHERE child_digest = @childDigest AND tenant_id = @tenantId
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId.ToString(), "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "childDigest", childDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapEdge,
|
||||
ct);
|
||||
var rows = await dbContext.SbomLineageEdges
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ChildDigest == childDigest && e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.Select(MapEdge).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<LineageEdge>> GetChildrenAsync(
|
||||
@@ -116,23 +111,18 @@ public sealed class SbomLineageEdgeRepository : RepositoryBase<LineageDataSource
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT id, parent_digest, child_digest, relationship, tenant_id, created_at
|
||||
FROM {FullTable}
|
||||
WHERE parent_digest = @parentDigest AND tenant_id = @tenantId
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId.ToString(), "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "parentDigest", parentDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapEdge,
|
||||
ct);
|
||||
var rows = await dbContext.SbomLineageEdges
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ParentDigest == parentDigest && e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.Select(MapEdge).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<LineageEdge> AddEdgeAsync(
|
||||
@@ -142,51 +132,45 @@ public sealed class SbomLineageEdgeRepository : RepositoryBase<LineageDataSource
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
INSERT INTO {FullTable} (parent_digest, child_digest, relationship, tenant_id)
|
||||
VALUES (@parentDigest, @childDigest, @relationship, @tenantId)
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId.ToString(), "writer", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for INSERT ... ON CONFLICT DO NOTHING RETURNING since EF Core
|
||||
// does not natively support upserts with RETURNING.
|
||||
var schemaName = GetSchemaName();
|
||||
var vRelationship = relationship.ToString().ToLowerInvariant();
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {schemaName}.sbom_lineage_edges (parent_digest, child_digest, relationship, tenant_id)
|
||||
VALUES ({"{0}"}, {"{1}"}, {"{2}"}, {"{3}"})
|
||||
ON CONFLICT (parent_digest, child_digest, tenant_id) DO NOTHING
|
||||
RETURNING id, parent_digest, child_digest, relationship, tenant_id, created_at
|
||||
""";
|
||||
|
||||
var result = await QuerySingleOrDefaultAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "parentDigest", parentDigest);
|
||||
AddParameter(cmd, "childDigest", childDigest);
|
||||
AddParameter(cmd, "relationship", relationship.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapEdge,
|
||||
ct);
|
||||
var result = await dbContext.SbomLineageEdges
|
||||
.FromSqlRaw(sql, parentDigest, childDigest, vRelationship, tenantId)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result == null)
|
||||
if (result is not null)
|
||||
{
|
||||
// Edge already exists, fetch it
|
||||
const string fetchSql = $"""
|
||||
SELECT id, parent_digest, child_digest, relationship, tenant_id, created_at
|
||||
FROM {FullTable}
|
||||
WHERE parent_digest = @parentDigest
|
||||
AND child_digest = @childDigest
|
||||
AND tenant_id = @tenantId
|
||||
""";
|
||||
|
||||
result = await QuerySingleOrDefaultAsync(
|
||||
tenantId.ToString(),
|
||||
fetchSql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "parentDigest", parentDigest);
|
||||
AddParameter(cmd, "childDigest", childDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapEdge,
|
||||
ct);
|
||||
return MapEdge(result);
|
||||
}
|
||||
|
||||
return result ?? throw new InvalidOperationException("Failed to create or retrieve lineage edge");
|
||||
// Edge already exists, fetch it
|
||||
var existing = await dbContext.SbomLineageEdges
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e =>
|
||||
e.ParentDigest == parentDigest &&
|
||||
e.ChildDigest == childDigest &&
|
||||
e.TenantId == tenantId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return existing is not null
|
||||
? MapEdge(existing)
|
||||
: throw new InvalidOperationException("Failed to create or retrieve lineage edge");
|
||||
}
|
||||
|
||||
public async ValueTask<bool> PathExistsAsync(
|
||||
@@ -229,19 +213,17 @@ public sealed class SbomLineageEdgeRepository : RepositoryBase<LineageDataSource
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Query sbom.sbom_versions table for node metadata
|
||||
// This assumes the table exists - adjust based on actual schema
|
||||
const string sql = """
|
||||
SELECT id, artifact_digest, sequence_number, created_at
|
||||
FROM sbom.sbom_versions
|
||||
WHERE artifact_digest = @digest AND tenant_id = @tenantId
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
// This table is outside the Lineage DbContext, so use raw SQL via the DataSource.
|
||||
try
|
||||
{
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
"""
|
||||
SELECT id, artifact_digest, sequence_number, created_at
|
||||
FROM sbom.sbom_versions
|
||||
WHERE artifact_digest = @digest AND tenant_id = @tenantId
|
||||
LIMIT 1
|
||||
""",
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "digest", artifactDigest);
|
||||
@@ -252,7 +234,7 @@ public sealed class SbomLineageEdgeRepository : RepositoryBase<LineageDataSource
|
||||
SbomVersionId: reader.GetGuid(reader.GetOrdinal("id")),
|
||||
SequenceNumber: reader.GetInt64(reader.GetOrdinal("sequence_number")),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
Metadata: null // TODO: Extract from labels/metadata columns
|
||||
Metadata: null
|
||||
),
|
||||
ct);
|
||||
}
|
||||
@@ -269,24 +251,33 @@ public sealed class SbomLineageEdgeRepository : RepositoryBase<LineageDataSource
|
||||
}
|
||||
}
|
||||
|
||||
private static LineageEdge MapEdge(System.Data.Common.DbDataReader reader)
|
||||
private string GetSchemaName()
|
||||
{
|
||||
var relationshipStr = reader.GetString(reader.GetOrdinal("relationship"));
|
||||
var relationship = relationshipStr.ToLowerInvariant() switch
|
||||
if (!string.IsNullOrWhiteSpace(DataSource.SchemaName))
|
||||
{
|
||||
return DataSource.SchemaName;
|
||||
}
|
||||
|
||||
return LineageDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
private static LineageEdge MapEdge(SbomLineageEdge row)
|
||||
{
|
||||
var relationship = row.Relationship.ToLowerInvariant() switch
|
||||
{
|
||||
"parent" => LineageRelationship.Parent,
|
||||
"build" => LineageRelationship.Build,
|
||||
"base" => LineageRelationship.Base,
|
||||
_ => throw new InvalidOperationException($"Unknown relationship: {relationshipStr}")
|
||||
_ => throw new InvalidOperationException($"Unknown relationship: {row.Relationship}")
|
||||
};
|
||||
|
||||
return new LineageEdge(
|
||||
Id: reader.GetGuid(reader.GetOrdinal("id")),
|
||||
ParentDigest: reader.GetString(reader.GetOrdinal("parent_digest")),
|
||||
ChildDigest: reader.GetString(reader.GetOrdinal("child_digest")),
|
||||
Id: row.Id,
|
||||
ParentDigest: row.ParentDigest,
|
||||
ChildDigest: row.ChildDigest,
|
||||
Relationship: relationship,
|
||||
TenantId: reader.GetGuid(reader.GetOrdinal("tenant_id")),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
|
||||
TenantId: row.TenantId,
|
||||
CreatedAt: new DateTimeOffset(row.CreatedAt, TimeSpan.Zero)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
using StellaOps.SbomService.Lineage.EfCore.Models;
|
||||
using StellaOps.SbomService.Lineage.Persistence;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of SBOM verdict link repository.
|
||||
/// EF Core implementation of SBOM verdict link repository.
|
||||
/// </summary>
|
||||
public sealed class SbomVerdictLinkRepository : RepositoryBase<LineageDataSource>, ISbomVerdictLinkRepository
|
||||
{
|
||||
private const string Schema = "sbom";
|
||||
private const string Table = "sbom_verdict_links";
|
||||
private const string FullTable = $"{Schema}.{Table}";
|
||||
|
||||
public SbomVerdictLinkRepository(
|
||||
LineageDataSource dataSource,
|
||||
ILogger<SbomVerdictLinkRepository> logger)
|
||||
@@ -23,15 +22,20 @@ public sealed class SbomVerdictLinkRepository : RepositoryBase<LineageDataSource
|
||||
|
||||
public async ValueTask<SbomVerdictLink> AddAsync(SbomVerdictLink link, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
INSERT INTO {FullTable} (
|
||||
await using var connection = await DataSource.OpenConnectionAsync(link.TenantId.ToString(), "writer", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for UPSERT with ON CONFLICT DO UPDATE RETURNING
|
||||
var schemaName = GetSchemaName();
|
||||
var statusStr = MapStatusToString(link.VerdictStatus);
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {schemaName}.sbom_verdict_links (
|
||||
sbom_version_id, cve, consensus_projection_id,
|
||||
verdict_status, confidence_score, tenant_id
|
||||
)
|
||||
VALUES (
|
||||
@sbomVersionId, @cve, @projectionId,
|
||||
@status, @confidence, @tenantId
|
||||
)
|
||||
VALUES ({"{0}"}, {"{1}"}, {"{2}"}, {"{3}"}, {"{4}"}, {"{5}"})
|
||||
ON CONFLICT (sbom_version_id, cve, tenant_id)
|
||||
DO UPDATE SET
|
||||
consensus_projection_id = EXCLUDED.consensus_projection_id,
|
||||
@@ -42,22 +46,17 @@ public sealed class SbomVerdictLinkRepository : RepositoryBase<LineageDataSource
|
||||
verdict_status, confidence_score, tenant_id, linked_at
|
||||
""";
|
||||
|
||||
var result = await QuerySingleOrDefaultAsync(
|
||||
link.TenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "sbomVersionId", link.SbomVersionId);
|
||||
AddParameter(cmd, "cve", link.Cve);
|
||||
AddParameter(cmd, "projectionId", link.ConsensusProjectionId);
|
||||
AddParameter(cmd, "status", link.VerdictStatus.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "confidence", link.ConfidenceScore);
|
||||
AddParameter(cmd, "tenantId", link.TenantId);
|
||||
},
|
||||
MapLink,
|
||||
ct);
|
||||
var result = await dbContext.SbomVerdictLinks
|
||||
.FromSqlRaw(sql,
|
||||
link.SbomVersionId, link.Cve, link.ConsensusProjectionId,
|
||||
statusStr, link.ConfidenceScore, link.TenantId)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Failed to add verdict link");
|
||||
return result is not null
|
||||
? MapLink(result)
|
||||
: throw new InvalidOperationException("Failed to add verdict link");
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<SbomVerdictLink>> GetBySbomVersionAsync(
|
||||
@@ -65,24 +64,18 @@ public sealed class SbomVerdictLinkRepository : RepositoryBase<LineageDataSource
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT sbom_version_id, cve, consensus_projection_id,
|
||||
verdict_status, confidence_score, tenant_id, linked_at
|
||||
FROM {FullTable}
|
||||
WHERE sbom_version_id = @sbomVersionId AND tenant_id = @tenantId
|
||||
ORDER BY cve ASC
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId.ToString(), "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "sbomVersionId", sbomVersionId);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapLink,
|
||||
ct);
|
||||
var rows = await dbContext.SbomVerdictLinks
|
||||
.AsNoTracking()
|
||||
.Where(e => e.SbomVersionId == sbomVersionId && e.TenantId == tenantId)
|
||||
.OrderBy(e => e.Cve)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.Select(MapLink).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<SbomVerdictLink?> GetByCveAsync(
|
||||
@@ -91,26 +84,19 @@ public sealed class SbomVerdictLinkRepository : RepositoryBase<LineageDataSource
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT sbom_version_id, cve, consensus_projection_id,
|
||||
verdict_status, confidence_score, tenant_id, linked_at
|
||||
FROM {FullTable}
|
||||
WHERE sbom_version_id = @sbomVersionId
|
||||
AND cve = @cve
|
||||
AND tenant_id = @tenantId
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId.ToString(), "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "sbomVersionId", sbomVersionId);
|
||||
AddParameter(cmd, "cve", cve);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapLink,
|
||||
ct);
|
||||
var row = await dbContext.SbomVerdictLinks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e =>
|
||||
e.SbomVersionId == sbomVersionId &&
|
||||
e.Cve == cve &&
|
||||
e.TenantId == tenantId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return row is not null ? MapLink(row) : null;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<SbomVerdictLink>> GetByCveAcrossVersionsAsync(
|
||||
@@ -119,26 +105,19 @@ public sealed class SbomVerdictLinkRepository : RepositoryBase<LineageDataSource
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT sbom_version_id, cve, consensus_projection_id,
|
||||
verdict_status, confidence_score, tenant_id, linked_at
|
||||
FROM {FullTable}
|
||||
WHERE cve = @cve AND tenant_id = @tenantId
|
||||
ORDER BY linked_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId.ToString(), "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "cve", cve);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapLink,
|
||||
ct);
|
||||
var rows = await dbContext.SbomVerdictLinks
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Cve == cve && e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.LinkedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.Select(MapLink).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask BatchAddAsync(
|
||||
@@ -148,7 +127,7 @@ public sealed class SbomVerdictLinkRepository : RepositoryBase<LineageDataSource
|
||||
if (links.Count == 0)
|
||||
return;
|
||||
|
||||
// Simple batch insert - could be optimized with COPY later
|
||||
// Simple batch insert - each uses upsert semantics
|
||||
foreach (var link in links)
|
||||
{
|
||||
await AddAsync(link, ct);
|
||||
@@ -161,51 +140,65 @@ public sealed class SbomVerdictLinkRepository : RepositoryBase<LineageDataSource
|
||||
decimal minConfidence = 0.8m,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT sbom_version_id, cve, consensus_projection_id,
|
||||
verdict_status, confidence_score, tenant_id, linked_at
|
||||
FROM {FullTable}
|
||||
WHERE sbom_version_id = @sbomVersionId
|
||||
AND tenant_id = @tenantId
|
||||
AND verdict_status = 'affected'
|
||||
AND confidence_score >= @minConfidence
|
||||
ORDER BY confidence_score DESC, cve ASC
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId.ToString(), "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "sbomVersionId", sbomVersionId);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "minConfidence", minConfidence);
|
||||
},
|
||||
MapLink,
|
||||
ct);
|
||||
var rows = await dbContext.SbomVerdictLinks
|
||||
.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.SbomVersionId == sbomVersionId &&
|
||||
e.TenantId == tenantId &&
|
||||
e.VerdictStatus == "affected" &&
|
||||
e.ConfidenceScore >= minConfidence)
|
||||
.OrderByDescending(e => e.ConfidenceScore)
|
||||
.ThenBy(e => e.Cve)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.Select(MapLink).ToList();
|
||||
}
|
||||
|
||||
private static SbomVerdictLink MapLink(System.Data.Common.DbDataReader reader)
|
||||
private string GetSchemaName()
|
||||
{
|
||||
var statusStr = reader.GetString(reader.GetOrdinal("verdict_status"));
|
||||
var status = statusStr.ToLowerInvariant() switch
|
||||
if (!string.IsNullOrWhiteSpace(DataSource.SchemaName))
|
||||
{
|
||||
"unknown" => VexStatus.Unknown,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
"affected" => VexStatus.Affected,
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
_ => throw new InvalidOperationException($"Unknown status: {statusStr}")
|
||||
};
|
||||
return DataSource.SchemaName;
|
||||
}
|
||||
|
||||
return LineageDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
private static string MapStatusToString(VexStatus status) => status switch
|
||||
{
|
||||
VexStatus.Unknown => "unknown",
|
||||
VexStatus.UnderInvestigation => "under_investigation",
|
||||
VexStatus.Affected => "affected",
|
||||
VexStatus.NotAffected => "not_affected",
|
||||
VexStatus.Fixed => "fixed",
|
||||
_ => throw new InvalidOperationException($"Unknown VEX status: {status}")
|
||||
};
|
||||
|
||||
private static VexStatus ParseStatus(string status) => status.ToLowerInvariant() switch
|
||||
{
|
||||
"unknown" => VexStatus.Unknown,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
"affected" => VexStatus.Affected,
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
_ => throw new InvalidOperationException($"Unknown VEX status: {status}")
|
||||
};
|
||||
|
||||
private static SbomVerdictLink MapLink(SbomVerdictLinkEntity row)
|
||||
{
|
||||
return new SbomVerdictLink(
|
||||
SbomVersionId: reader.GetGuid(reader.GetOrdinal("sbom_version_id")),
|
||||
Cve: reader.GetString(reader.GetOrdinal("cve")),
|
||||
ConsensusProjectionId: reader.GetGuid(reader.GetOrdinal("consensus_projection_id")),
|
||||
VerdictStatus: status,
|
||||
ConfidenceScore: reader.GetDecimal(reader.GetOrdinal("confidence_score")),
|
||||
TenantId: reader.GetGuid(reader.GetOrdinal("tenant_id")),
|
||||
LinkedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("linked_at"))
|
||||
SbomVersionId: row.SbomVersionId,
|
||||
Cve: row.Cve,
|
||||
ConsensusProjectionId: row.ConsensusProjectionId,
|
||||
VerdictStatus: ParseStatus(row.VerdictStatus),
|
||||
ConfidenceScore: row.ConfidenceScore,
|
||||
TenantId: row.TenantId,
|
||||
LinkedAt: new DateTimeOffset(row.LinkedAt, TimeSpan.Zero)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
using StellaOps.SbomService.Lineage.EfCore.Models;
|
||||
using StellaOps.SbomService.Lineage.Persistence;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of VEX delta repository.
|
||||
/// EF Core implementation of VEX delta repository.
|
||||
/// </summary>
|
||||
public sealed class VexDeltaRepository : RepositoryBase<LineageDataSource>, IVexDeltaRepository
|
||||
{
|
||||
private const string Schema = "vex";
|
||||
private const string Table = "vex_deltas";
|
||||
private const string FullTable = $"{Schema}.{Table}";
|
||||
|
||||
public VexDeltaRepository(
|
||||
LineageDataSource dataSource,
|
||||
ILogger<VexDeltaRepository> logger)
|
||||
@@ -25,15 +23,24 @@ public sealed class VexDeltaRepository : RepositoryBase<LineageDataSource>, IVex
|
||||
|
||||
public async ValueTask<VexDelta> AddAsync(VexDelta delta, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
INSERT INTO {FullTable} (
|
||||
await using var connection = await DataSource.OpenConnectionAsync(delta.TenantId.ToString(), "writer", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for UPSERT with ON CONFLICT DO UPDATE RETURNING.
|
||||
// The vex_deltas table lives in the "vex" schema.
|
||||
var vexSchema = GetVexSchemaName();
|
||||
var fromStatusStr = MapStatusToString(delta.FromStatus);
|
||||
var toStatusStr = MapStatusToString(delta.ToStatus);
|
||||
var rationaleJson = SerializeRationale(delta.Rationale);
|
||||
var attestationDigest = (object?)delta.AttestationDigest ?? DBNull.Value;
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {vexSchema}.vex_deltas (
|
||||
tenant_id, from_artifact_digest, to_artifact_digest, cve,
|
||||
from_status, to_status, rationale, replay_hash, attestation_digest
|
||||
)
|
||||
VALUES (
|
||||
@tenantId, @fromDigest, @toDigest, @cve,
|
||||
@fromStatus, @toStatus, @rationale::jsonb, @replayHash, @attestationDigest
|
||||
)
|
||||
VALUES ({"{0}"}, {"{1}"}, {"{2}"}, {"{3}"}, {"{4}"}, {"{5}"}, {"{6}"}::jsonb, {"{7}"}, {"{8}"})
|
||||
ON CONFLICT (tenant_id, from_artifact_digest, to_artifact_digest, cve)
|
||||
DO UPDATE SET
|
||||
to_status = EXCLUDED.to_status,
|
||||
@@ -44,25 +51,18 @@ public sealed class VexDeltaRepository : RepositoryBase<LineageDataSource>, IVex
|
||||
from_status, to_status, rationale, replay_hash, attestation_digest, created_at
|
||||
""";
|
||||
|
||||
var result = await QuerySingleOrDefaultAsync(
|
||||
delta.TenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenantId", delta.TenantId);
|
||||
AddParameter(cmd, "fromDigest", delta.FromArtifactDigest);
|
||||
AddParameter(cmd, "toDigest", delta.ToArtifactDigest);
|
||||
AddParameter(cmd, "cve", delta.Cve);
|
||||
AddParameter(cmd, "fromStatus", delta.FromStatus.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "toStatus", delta.ToStatus.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "rationale", SerializeRationale(delta.Rationale));
|
||||
AddParameter(cmd, "replayHash", delta.ReplayHash);
|
||||
AddParameter(cmd, "attestationDigest", (object?)delta.AttestationDigest ?? DBNull.Value);
|
||||
},
|
||||
MapDelta,
|
||||
ct);
|
||||
var result = await dbContext.VexDeltas
|
||||
.FromSqlRaw(sql,
|
||||
delta.TenantId, delta.FromArtifactDigest, delta.ToArtifactDigest, delta.Cve,
|
||||
fromStatusStr, toStatusStr, rationaleJson, delta.ReplayHash,
|
||||
delta.AttestationDigest ?? (object)DBNull.Value)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Failed to add VEX delta");
|
||||
return result is not null
|
||||
? MapDelta(result)
|
||||
: throw new InvalidOperationException("Failed to add VEX delta");
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<VexDelta>> GetDeltasAsync(
|
||||
@@ -71,27 +71,21 @@ public sealed class VexDeltaRepository : RepositoryBase<LineageDataSource>, IVex
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT id, tenant_id, from_artifact_digest, to_artifact_digest, cve,
|
||||
from_status, to_status, rationale, replay_hash, attestation_digest, created_at
|
||||
FROM {FullTable}
|
||||
WHERE from_artifact_digest = @fromDigest
|
||||
AND to_artifact_digest = @toDigest
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY cve ASC
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId.ToString(), "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "fromDigest", fromDigest);
|
||||
AddParameter(cmd, "toDigest", toDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapDelta,
|
||||
ct);
|
||||
var rows = await dbContext.VexDeltas
|
||||
.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.FromArtifactDigest == fromDigest &&
|
||||
e.ToArtifactDigest == toDigest &&
|
||||
e.TenantId == tenantId)
|
||||
.OrderBy(e => e.Cve)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.Select(MapDelta).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<VexDelta>> GetDeltasByCveAsync(
|
||||
@@ -100,26 +94,19 @@ public sealed class VexDeltaRepository : RepositoryBase<LineageDataSource>, IVex
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, tenant_id, from_artifact_digest, to_artifact_digest, cve,
|
||||
from_status, to_status, rationale, replay_hash, attestation_digest, created_at
|
||||
FROM {FullTable}
|
||||
WHERE cve = @cve AND tenant_id = @tenantId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId.ToString(), "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "cve", cve);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapDelta,
|
||||
ct);
|
||||
var rows = await dbContext.VexDeltas
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Cve == cve && e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.Select(MapDelta).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<VexDelta>> GetDeltasToArtifactAsync(
|
||||
@@ -127,24 +114,19 @@ public sealed class VexDeltaRepository : RepositoryBase<LineageDataSource>, IVex
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT id, tenant_id, from_artifact_digest, to_artifact_digest, cve,
|
||||
from_status, to_status, rationale, replay_hash, attestation_digest, created_at
|
||||
FROM {FullTable}
|
||||
WHERE to_artifact_digest = @toDigest AND tenant_id = @tenantId
|
||||
ORDER BY created_at DESC, cve ASC
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId.ToString(), "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "toDigest", toDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapDelta,
|
||||
ct);
|
||||
var rows = await dbContext.VexDeltas
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ToArtifactDigest == toDigest && e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ThenBy(e => e.Cve)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.Select(MapDelta).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<VexDelta>> GetStatusChangesAsync(
|
||||
@@ -152,50 +134,53 @@ public sealed class VexDeltaRepository : RepositoryBase<LineageDataSource>, IVex
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT id, tenant_id, from_artifact_digest, to_artifact_digest, cve,
|
||||
from_status, to_status, rationale, replay_hash, attestation_digest, created_at
|
||||
FROM {FullTable}
|
||||
WHERE (from_artifact_digest = @digest OR to_artifact_digest = @digest)
|
||||
AND from_status != to_status
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId.ToString(), "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = LineageDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "digest", artifactDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapDelta,
|
||||
ct);
|
||||
var rows = await dbContext.VexDeltas
|
||||
.AsNoTracking()
|
||||
.Where(e =>
|
||||
(e.FromArtifactDigest == artifactDigest || e.ToArtifactDigest == artifactDigest) &&
|
||||
e.FromStatus != e.ToStatus &&
|
||||
e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.Select(MapDelta).ToList();
|
||||
}
|
||||
|
||||
private static VexDelta MapDelta(System.Data.Common.DbDataReader reader)
|
||||
private string GetSchemaName()
|
||||
{
|
||||
var fromStatusStr = reader.GetString(reader.GetOrdinal("from_status"));
|
||||
var toStatusStr = reader.GetString(reader.GetOrdinal("to_status"));
|
||||
if (!string.IsNullOrWhiteSpace(DataSource.SchemaName))
|
||||
{
|
||||
return DataSource.SchemaName;
|
||||
}
|
||||
|
||||
return new VexDelta(
|
||||
Id: reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId: reader.GetGuid(reader.GetOrdinal("tenant_id")),
|
||||
FromArtifactDigest: reader.GetString(reader.GetOrdinal("from_artifact_digest")),
|
||||
ToArtifactDigest: reader.GetString(reader.GetOrdinal("to_artifact_digest")),
|
||||
Cve: reader.GetString(reader.GetOrdinal("cve")),
|
||||
FromStatus: ParseStatus(fromStatusStr),
|
||||
ToStatus: ParseStatus(toStatusStr),
|
||||
Rationale: DeserializeRationale(reader.GetString(reader.GetOrdinal("rationale"))),
|
||||
ReplayHash: reader.GetString(reader.GetOrdinal("replay_hash")),
|
||||
AttestationDigest: reader.IsDBNull(reader.GetOrdinal("attestation_digest"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("attestation_digest")),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
|
||||
);
|
||||
return LineageDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
private string GetVexSchemaName()
|
||||
{
|
||||
var schema = GetSchemaName();
|
||||
// In production, the vex_deltas table is in the "vex" schema.
|
||||
// For integration tests with non-default schemas, use the same schema.
|
||||
return string.Equals(schema, LineageDataSource.DefaultSchemaName, StringComparison.Ordinal)
|
||||
? "vex"
|
||||
: schema;
|
||||
}
|
||||
|
||||
private static string MapStatusToString(VexStatus status) => status switch
|
||||
{
|
||||
VexStatus.Unknown => "unknown",
|
||||
VexStatus.UnderInvestigation => "under_investigation",
|
||||
VexStatus.Affected => "affected",
|
||||
VexStatus.NotAffected => "not_affected",
|
||||
VexStatus.Fixed => "fixed",
|
||||
_ => throw new InvalidOperationException($"Unknown VEX status: {status}")
|
||||
};
|
||||
|
||||
private static VexStatus ParseStatus(string status) => status.ToLowerInvariant() switch
|
||||
{
|
||||
"unknown" => VexStatus.Unknown,
|
||||
@@ -232,4 +217,21 @@ public sealed class VexDeltaRepository : RepositoryBase<LineageDataSource>, IVex
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
private static VexDelta MapDelta(VexDeltaEntity row)
|
||||
{
|
||||
return new VexDelta(
|
||||
Id: row.Id,
|
||||
TenantId: row.TenantId,
|
||||
FromArtifactDigest: row.FromArtifactDigest,
|
||||
ToArtifactDigest: row.ToArtifactDigest,
|
||||
Cve: row.Cve,
|
||||
FromStatus: ParseStatus(row.FromStatus),
|
||||
ToStatus: ParseStatus(row.ToStatus),
|
||||
Rationale: DeserializeRationale(row.Rationale),
|
||||
ReplayHash: row.ReplayHash,
|
||||
AttestationDigest: row.AttestationDigest,
|
||||
CreatedAt: new DateTimeOffset(row.CreatedAt, TimeSpan.Zero)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,26 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Persistence\Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\LineageDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.EfCore/StellaOps.Infrastructure.EfCore.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# SbomService Lineage Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260222_073_SbomService_lineage_dal_to_efcore.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0764-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0764-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0764-A | DONE | Already compliant (TreatWarningsAsErrors). |
|
||||
| SBOMLIN-EF-01 | DONE | AGENTS.md verified; migration plugin registered in Platform MigrationModulePlugins. |
|
||||
| SBOMLIN-EF-02 | DONE | EF Core models, DbContext (main + partial), and design-time factory created. |
|
||||
| SBOMLIN-EF-03 | DONE | All 3 repositories converted to EF Core (LINQ reads, raw SQL for upserts). |
|
||||
| SBOMLIN-EF-04 | DONE | Compiled model stubs created; runtime factory with UseModel(); assembly attribute excluded. |
|
||||
| SBOMLIN-EF-05 | DONE | Sequential build and tests pass (34/34); .csproj updated; docs updated. |
|
||||
|
||||
Reference in New Issue
Block a user