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

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

View File

@@ -1,5 +1,6 @@
using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Concelier.Persistence.Postgres.Repositories;
namespace StellaOps.Concelier.WebService.Extensions;
@@ -14,7 +15,8 @@ internal static class AdvisorySourceEndpointExtensions
public static void MapAdvisorySourceEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/advisory-sources")
.WithTags("Advisory Sources");
.WithTags("Advisory Sources")
.RequireTenant();
group.MapGet(string.Empty, async (
HttpContext httpContext,
@@ -23,11 +25,6 @@ internal static class AdvisorySourceEndpointExtensions
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out _))
{
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
}
var records = await readRepository.ListAsync(includeDisabled, cancellationToken).ConfigureAwait(false);
var items = records.Select(MapListItem).ToList();
@@ -40,6 +37,7 @@ internal static class AdvisorySourceEndpointExtensions
})
.WithName("ListAdvisorySources")
.WithSummary("List advisory sources with freshness state")
.WithDescription("Returns all registered advisory sources with their current freshness status, sync timestamps, and signature validity. Supports filtering of disabled sources via query parameter.")
.Produces<AdvisorySourceListResponse>(StatusCodes.Status200OK)
.RequireAuthorization(AdvisoryReadPolicy);
@@ -49,11 +47,6 @@ internal static class AdvisorySourceEndpointExtensions
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out _))
{
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
}
var records = await readRepository.ListAsync(includeDisabled: true, cancellationToken).ConfigureAwait(false);
var response = new AdvisorySourceSummaryResponse
{
@@ -71,6 +64,7 @@ internal static class AdvisorySourceEndpointExtensions
})
.WithName("GetAdvisorySourceSummary")
.WithSummary("Get advisory source summary cards")
.WithDescription("Returns aggregated health counters across all advisory sources: healthy, warning, stale, unavailable, and disabled counts. Used by the UI v2 dashboard header cards.")
.Produces<AdvisorySourceSummaryResponse>(StatusCodes.Status200OK)
.RequireAuthorization(AdvisoryReadPolicy);
@@ -82,11 +76,6 @@ internal static class AdvisorySourceEndpointExtensions
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out _))
{
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
}
if (string.IsNullOrWhiteSpace(id))
{
return HttpResults.BadRequest(new { error = "source_id_required" });
@@ -126,32 +115,12 @@ internal static class AdvisorySourceEndpointExtensions
})
.WithName("GetAdvisorySourceFreshness")
.WithSummary("Get freshness details for one advisory source")
.WithDescription("Returns detailed freshness metrics for a single advisory source identified by GUID or source key. Includes last sync time, last success time, error count, and SLA tracking.")
.Produces<AdvisorySourceFreshnessResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(AdvisoryReadPolicy);
}
private static bool TryGetTenant(HttpContext httpContext, out string tenant)
{
tenant = string.Empty;
var claimTenant = httpContext.User?.FindFirst("tenant_id")?.Value;
if (!string.IsNullOrWhiteSpace(claimTenant))
{
tenant = claimTenant.Trim();
return true;
}
var headerTenant = httpContext.Request.Headers[StellaOps.Concelier.WebService.Program.TenantHeaderName].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(headerTenant))
{
tenant = headerTenant.Trim();
return true;
}
return false;
}
private static AdvisorySourceListItem MapListItem(AdvisorySourceFreshnessRecord record)
{
return new AdvisorySourceListItem

View File

@@ -3,6 +3,7 @@ using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Concelier.Core.AirGap;
using StellaOps.Concelier.Core.AirGap.Models;
using StellaOps.Concelier.WebService.Diagnostics;
@@ -21,7 +22,8 @@ internal static class AirGapEndpointExtensions
public static void MapConcelierAirGapEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/concelier/airgap")
.WithTags("AirGap");
.WithTags("AirGap")
.RequireTenant();
// GET /api/v1/concelier/airgap/catalog - Aggregated bundle catalog
group.MapGet("/catalog", async (
@@ -42,7 +44,11 @@ internal static class AirGapEndpointExtensions
.ConfigureAwait(false);
return HttpResults.Ok(catalog);
});
})
.WithName("GetAirGapCatalog")
.WithSummary("Get aggregated air-gap bundle catalog")
.WithDescription("Returns the paginated catalog of all available air-gap bundles from registered sources. Requires the air-gap feature to be enabled in configuration.")
.RequireAuthorization("Concelier.Advisories.Read");
// GET /api/v1/concelier/airgap/sources - List registered sources
group.MapGet("/sources", (
@@ -58,7 +64,11 @@ internal static class AirGapEndpointExtensions
var sources = sourceRegistry.GetSources();
return HttpResults.Ok(new { sources, count = sources.Count });
});
})
.WithName("ListAirGapSources")
.WithSummary("List registered air-gap bundle sources")
.WithDescription("Returns all bundle sources currently registered in the air-gap source registry.")
.RequireAuthorization("Concelier.Advisories.Read");
// POST /api/v1/concelier/airgap/sources - Register new source
group.MapPost("/sources", async (
@@ -83,7 +93,11 @@ internal static class AirGapEndpointExtensions
.ConfigureAwait(false);
return HttpResults.Created($"/api/v1/concelier/airgap/sources/{source.Id}", source);
});
})
.WithName("RegisterAirGapSource")
.WithSummary("Register a new air-gap bundle source")
.WithDescription("Registers a new bundle source in the air-gap source registry. The source ID must be unique and is used to identify the source in subsequent operations.")
.RequireAuthorization("Concelier.Advisories.Ingest");
// GET /api/v1/concelier/airgap/sources/{sourceId} - Get specific source
group.MapGet("/sources/{sourceId}", (
@@ -105,7 +119,11 @@ internal static class AirGapEndpointExtensions
}
return HttpResults.Ok(source);
});
})
.WithName("GetAirGapSource")
.WithSummary("Get a specific air-gap bundle source")
.WithDescription("Returns the registration details for a specific bundle source identified by its source ID.")
.RequireAuthorization("Concelier.Advisories.Read");
// DELETE /api/v1/concelier/airgap/sources/{sourceId} - Unregister source
group.MapDelete("/sources/{sourceId}", async (
@@ -127,7 +145,11 @@ internal static class AirGapEndpointExtensions
return removed
? HttpResults.NoContent()
: ConcelierProblemResultFactory.BundleSourceNotFound(context, sourceId);
});
})
.WithName("UnregisterAirGapSource")
.WithSummary("Unregister an air-gap bundle source")
.WithDescription("Removes a bundle source from the air-gap registry. This does not delete any previously downloaded bundles.")
.RequireAuthorization("Concelier.Advisories.Ingest");
// POST /api/v1/concelier/airgap/sources/{sourceId}/validate - Validate source
group.MapPost("/sources/{sourceId}/validate", async (
@@ -147,7 +169,11 @@ internal static class AirGapEndpointExtensions
.ConfigureAwait(false);
return HttpResults.Ok(result);
});
})
.WithName("ValidateAirGapSource")
.WithSummary("Validate an air-gap bundle source")
.WithDescription("Runs connectivity and integrity checks against a registered bundle source to verify it is reachable and correctly configured.")
.RequireAuthorization("Concelier.Advisories.Ingest");
// GET /api/v1/concelier/airgap/status - Sealed-mode status
group.MapGet("/status", (
@@ -163,7 +189,11 @@ internal static class AirGapEndpointExtensions
var status = sealedModeEnforcer.GetStatus();
return HttpResults.Ok(status);
});
})
.WithName("GetAirGapStatus")
.WithSummary("Get air-gap sealed-mode status")
.WithDescription("Returns the current sealed-mode enforcement status, indicating whether the node is operating in full air-gap mode with all external network access blocked.")
.RequireAuthorization("Concelier.Advisories.Read");
// POST /api/v1/concelier/airgap/bundles/{bundleId}/import - Import a bundle with timeline event
// Per CONCELIER-WEB-AIRGAP-58-001
@@ -251,7 +281,11 @@ internal static class AirGapEndpointExtensions
Stats = importStats,
OccurredAt = timelineEvent.OccurredAt
});
});
})
.WithName("ImportAirGapBundle")
.WithSummary("Import an air-gap bundle with timeline event")
.WithDescription("Imports a specific bundle from the catalog into the tenant's advisory database and emits a timeline event recording the import actor, scope, and statistics.")
.RequireAuthorization("Concelier.Advisories.Ingest");
}
}

View File

@@ -8,6 +8,7 @@
using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Concelier.Core.Canonical;
using StellaOps.Concelier.Interest;
using StellaOps.Concelier.Merge.Backport;
@@ -26,7 +27,8 @@ internal static class CanonicalAdvisoryEndpointExtensions
public static void MapCanonicalAdvisoryEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/canonical")
.WithTags("Canonical Advisories");
.WithTags("Canonical Advisories")
.RequireTenant();
// GET /api/v1/canonical/{id} - Get canonical advisory by ID
group.MapGet("/{id:guid}", async (
@@ -54,8 +56,10 @@ internal static class CanonicalAdvisoryEndpointExtensions
})
.WithName("GetCanonicalById")
.WithSummary("Get canonical advisory by ID")
.WithDescription("Returns the merged canonical advisory record by its unique GUID, including all source edges, interest score, version range, and weaknesses.")
.Produces<CanonicalAdvisoryResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(CanonicalReadPolicy);
// GET /api/v1/canonical?cve={cve}&artifact={artifact} - Query canonical advisories
group.MapGet("/", async (
@@ -121,7 +125,9 @@ internal static class CanonicalAdvisoryEndpointExtensions
})
.WithName("QueryCanonical")
.WithSummary("Query canonical advisories by CVE, artifact, or merge hash")
.Produces<CanonicalAdvisoryListResponse>(StatusCodes.Status200OK);
.WithDescription("Searches canonical advisories by CVE identifier, artifact package URL, or merge hash. Query by merge hash takes precedence. Falls back to paginated generic query when no filter is specified.")
.Produces<CanonicalAdvisoryListResponse>(StatusCodes.Status200OK)
.RequireAuthorization(CanonicalReadPolicy);
// POST /api/v1/canonical/ingest/{source} - Ingest raw advisory
group.MapPost("/ingest/{source}", async (
@@ -181,9 +187,11 @@ internal static class CanonicalAdvisoryEndpointExtensions
})
.WithName("IngestAdvisory")
.WithSummary("Ingest raw advisory from source into canonical pipeline")
.WithDescription("Ingests a single raw advisory from the named source into the canonical merge pipeline. Returns the merge decision (Created, Merged, Duplicate, or Conflict) and the resulting canonical ID.")
.Produces<IngestResultResponse>(StatusCodes.Status200OK)
.Produces<IngestResultResponse>(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status400BadRequest);
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(CanonicalIngestPolicy);
// POST /api/v1/canonical/ingest/{source}/batch - Batch ingest advisories
group.MapPost("/ingest/{source}/batch", async (
@@ -243,8 +251,10 @@ internal static class CanonicalAdvisoryEndpointExtensions
})
.WithName("IngestAdvisoryBatch")
.WithSummary("Batch ingest multiple advisories from source")
.WithDescription("Ingests a batch of raw advisories from the named source into the canonical merge pipeline. Returns per-item merge decisions and a summary with total created, merged, duplicate, and conflict counts.")
.Produces<BatchIngestResultResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(CanonicalIngestPolicy);
// PATCH /api/v1/canonical/{id}/status - Update canonical status
group.MapPatch("/{id:guid}/status", async (
@@ -265,8 +275,10 @@ internal static class CanonicalAdvisoryEndpointExtensions
})
.WithName("UpdateCanonicalStatus")
.WithSummary("Update canonical advisory status")
.WithDescription("Updates the lifecycle status (Active, Disputed, Suppressed, Withdrawn) of a canonical advisory. Used by triage workflows to manage advisory state.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(CanonicalIngestPolicy);
// GET /api/v1/canonical/{id}/provenance - Get provenance scopes for canonical
group.MapGet("/{id:guid}/provenance", async (
@@ -304,9 +316,10 @@ internal static class CanonicalAdvisoryEndpointExtensions
})
.WithName("GetCanonicalProvenance")
.WithSummary("Get provenance scopes for canonical advisory")
.WithDescription("Returns distro-specific backport and patch provenance information for a canonical advisory")
.WithDescription("Returns distro-specific backport and patch provenance scopes for a canonical advisory, including patch origin, evidence references, and confidence scores per distribution release.")
.Produces<ProvenanceScopeListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(CanonicalReadPolicy);
}
private static ProvenanceScopeResponse MapToProvenanceResponse(ProvenanceScope scope) => new()

View File

@@ -2,6 +2,7 @@
using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Concelier.Federation.Export;
using StellaOps.Concelier.Federation.Import;
using StellaOps.Concelier.Federation.Models;
@@ -20,7 +21,8 @@ internal static class FederationEndpointExtensions
public static void MapConcelierFederationEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/federation")
.WithTags("Federation");
.WithTags("Federation")
.RequireTenant();
// GET /api/v1/federation/export - Export delta bundle
group.MapGet("/export", async (
@@ -84,9 +86,11 @@ internal static class FederationEndpointExtensions
})
.WithName("ExportFederationBundle")
.WithSummary("Export delta bundle for federation sync")
.WithDescription("Generates and streams a zstd-compressed delta bundle of canonical advisories since the specified cursor. Optionally signs the bundle. Used for inter-node federation replication.")
.Produces(200, contentType: "application/zstd")
.ProducesProblem(400)
.ProducesProblem(503);
.ProducesProblem(503)
.RequireAuthorization("Concelier.Advisories.Read");
// GET /api/v1/federation/export/preview - Preview export statistics
group.MapGet("/export/preview", async (
@@ -116,8 +120,10 @@ internal static class FederationEndpointExtensions
})
.WithName("PreviewFederationExport")
.WithSummary("Preview export statistics without creating bundle")
.WithDescription("Returns estimated counts of canonicals, edges, and deletions that would be included in a delta bundle since the specified cursor, without generating the actual bundle.")
.Produces<object>(200)
.ProducesProblem(503);
.ProducesProblem(503)
.RequireAuthorization("Concelier.Advisories.Read");
// GET /api/v1/federation/status - Federation status
group.MapGet("/status", (
@@ -136,7 +142,9 @@ internal static class FederationEndpointExtensions
})
.WithName("GetFederationStatus")
.WithSummary("Get federation configuration status")
.Produces<object>(200);
.WithDescription("Returns the current federation configuration including enabled state, site ID, and default operational parameters for compression and item limits.")
.Produces<object>(200)
.RequireAuthorization("Concelier.Advisories.Read");
// POST /api/v1/federation/import - Import a bundle
// Per SPRINT_8200_0014_0003_CONCEL_bundle_import_merge Task 25-26.
@@ -228,12 +236,14 @@ internal static class FederationEndpointExtensions
})
.WithName("ImportFederationBundle")
.WithSummary("Import a federation bundle")
.WithDescription("Imports a zstd-compressed federation bundle into the canonical advisory database. Supports dry-run mode, signature skipping, and configurable conflict resolution strategy (PreferRemote, PreferLocal, Fail).")
.Accepts<Stream>("application/zstd")
.Produces<object>(200)
.ProducesProblem(400)
.ProducesProblem(422)
.ProducesProblem(503)
.DisableAntiforgery();
.DisableAntiforgery()
.RequireAuthorization("Concelier.Advisories.Ingest");
// POST /api/v1/federation/import/validate - Validate bundle without importing
group.MapPost("/import/validate", async (
@@ -264,10 +274,12 @@ internal static class FederationEndpointExtensions
})
.WithName("ValidateFederationBundle")
.WithSummary("Validate a bundle without importing")
.WithDescription("Performs structural and cryptographic validation of a zstd-compressed federation bundle without persisting any data. Returns hash validity, signature validity, cursor validity, and any validation errors or warnings.")
.Accepts<Stream>("application/zstd")
.Produces<object>(200)
.ProducesProblem(503)
.DisableAntiforgery();
.DisableAntiforgery()
.RequireAuthorization("Concelier.Advisories.Ingest");
// POST /api/v1/federation/import/preview - Preview import
group.MapPost("/import/preview", async (
@@ -312,10 +324,12 @@ internal static class FederationEndpointExtensions
})
.WithName("PreviewFederationImport")
.WithSummary("Preview what import would do")
.WithDescription("Inspects a zstd-compressed federation bundle and returns its manifest details including item counts, site ID, export cursor, and duplicate detection status, without applying any changes to the advisory database.")
.Accepts<Stream>("application/zstd")
.Produces<object>(200)
.ProducesProblem(503)
.DisableAntiforgery();
.DisableAntiforgery()
.RequireAuthorization("Concelier.Advisories.Ingest");
// GET /api/v1/federation/sites - List all federation sites
// Per SPRINT_8200_0014_0003_CONCEL_bundle_import_merge Task 30.
@@ -352,8 +366,10 @@ internal static class FederationEndpointExtensions
})
.WithName("ListFederationSites")
.WithSummary("List all federation sites")
.WithDescription("Returns all registered federation peer sites with their sync state, last cursor, import counts, and access policy. Supports filtering to enabled sites only via the enabled_only query parameter.")
.Produces<object>(200)
.ProducesProblem(503);
.ProducesProblem(503)
.RequireAuthorization("Concelier.Advisories.Read");
// GET /api/v1/federation/sites/{siteId} - Get site details
group.MapGet("/sites/{siteId}", async (
@@ -404,9 +420,11 @@ internal static class FederationEndpointExtensions
})
.WithName("GetFederationSite")
.WithSummary("Get federation site details")
.WithDescription("Returns full configuration and recent sync history for a specific federation peer site identified by site ID. Includes the last 10 import history entries with cursor, hash, and item counts per sync.")
.Produces<object>(200)
.ProducesProblem(404)
.ProducesProblem(503);
.ProducesProblem(503)
.RequireAuthorization("Concelier.Advisories.Read");
// PUT /api/v1/federation/sites/{siteId}/policy - Update site policy
// Per SPRINT_8200_0014_0003_CONCEL_bundle_import_merge Task 31.
@@ -450,9 +468,11 @@ internal static class FederationEndpointExtensions
})
.WithName("UpdateFederationSitePolicy")
.WithSummary("Update federation site policy")
.WithDescription("Creates or updates the access policy for a federation peer site, controlling its enabled state, allowed advisory sources, and maximum bundle size. Existing values are preserved for any fields not provided in the request body.")
.Produces<object>(200)
.ProducesProblem(400)
.ProducesProblem(503);
.ProducesProblem(503)
.RequireAuthorization("Concelier.Advisories.Ingest");
}
}

View File

@@ -1,6 +1,7 @@
using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
namespace StellaOps.Concelier.WebService.Extensions;
@@ -15,55 +16,162 @@ internal static class FeedMirrorManagementEndpoints
{
// Mirror management
var mirrors = app.MapGroup("/api/v1/concelier/mirrors")
.WithTags("FeedMirrors");
.WithTags("FeedMirrors")
.RequireAuthorization("Concelier.Advisories.Read")
.RequireTenant();
mirrors.MapGet(string.Empty, ListMirrors);
mirrors.MapGet("/{mirrorId}", GetMirror);
mirrors.MapPatch("/{mirrorId}", UpdateMirrorConfig);
mirrors.MapPost("/{mirrorId}/sync", TriggerSync);
mirrors.MapGet("/{mirrorId}/snapshots", ListMirrorSnapshots);
mirrors.MapGet("/{mirrorId}/retention", GetRetentionConfig);
mirrors.MapPut("/{mirrorId}/retention", UpdateRetentionConfig);
mirrors.MapGet(string.Empty, ListMirrors)
.WithName("ListFeedMirrors")
.WithSummary("List feed mirrors")
.WithDescription("Returns all registered feed mirrors with their sync status, last sync timestamp, upstream URL, and snapshot counts. Supports filtering by feed type, sync status, enabled state, and name search.")
.RequireAuthorization("Concelier.Advisories.Read");
mirrors.MapGet("/{mirrorId}", GetMirror)
.WithName("GetFeedMirror")
.WithSummary("Get feed mirror details")
.WithDescription("Returns the full configuration and current state for a specific feed mirror identified by its mirror ID, including sync schedule, storage usage, and latest snapshot reference.")
.RequireAuthorization("Concelier.Advisories.Read");
mirrors.MapPatch("/{mirrorId}", UpdateMirrorConfig)
.WithName("UpdateFeedMirrorConfig")
.WithSummary("Update feed mirror configuration")
.WithDescription("Updates the configuration of a specific feed mirror, including enabled state, sync interval, and upstream URL. Only provided fields are modified; all others retain their current values.")
.RequireAuthorization("Concelier.Advisories.Ingest");
mirrors.MapPost("/{mirrorId}/sync", TriggerSync)
.WithName("TriggerFeedMirrorSync")
.WithSummary("Trigger an immediate sync for a feed mirror")
.WithDescription("Initiates an out-of-schedule synchronization for the specified feed mirror. Returns the resulting snapshot ID and record count upon completion.")
.RequireAuthorization("Concelier.Jobs.Trigger");
mirrors.MapGet("/{mirrorId}/snapshots", ListMirrorSnapshots)
.WithName("ListMirrorSnapshots")
.WithSummary("List snapshots for a feed mirror")
.WithDescription("Returns all stored snapshots for a specific feed mirror, including version, creation time, size, checksums, record count, and pinned state.")
.RequireAuthorization("Concelier.Advisories.Read");
mirrors.MapGet("/{mirrorId}/retention", GetRetentionConfig)
.WithName("GetMirrorRetentionConfig")
.WithSummary("Get snapshot retention configuration for a mirror")
.WithDescription("Returns the active snapshot retention policy for the specified mirror, including retention mode (keep_n), maximum kept snapshot count, and whether pinned snapshots are excluded from pruning.")
.RequireAuthorization("Concelier.Advisories.Read");
mirrors.MapPut("/{mirrorId}/retention", UpdateRetentionConfig)
.WithName("UpdateMirrorRetentionConfig")
.WithSummary("Update snapshot retention configuration for a mirror")
.WithDescription("Sets the snapshot retention policy for a specific feed mirror. Supports keep_n mode with configurable count and pin exclusion. Existing snapshots exceeding the new limit are pruned on the next scheduled cleanup.")
.RequireAuthorization("Concelier.Advisories.Ingest");
// Snapshot operations (by snapshotId)
var snapshots = app.MapGroup("/api/v1/concelier/snapshots")
.WithTags("FeedSnapshots");
.WithTags("FeedSnapshots")
.RequireAuthorization("Concelier.Advisories.Read")
.RequireTenant();
snapshots.MapGet("/{snapshotId}", GetSnapshot);
snapshots.MapPost("/{snapshotId}/download", DownloadSnapshot);
snapshots.MapPatch("/{snapshotId}", PinSnapshot);
snapshots.MapDelete("/{snapshotId}", DeleteSnapshot);
snapshots.MapGet("/{snapshotId}", GetSnapshot)
.WithName("GetMirrorFeedSnapshot")
.WithSummary("Get feed snapshot details")
.WithDescription("Returns the metadata and download URL for a specific feed snapshot identified by its snapshot ID, including size, checksums, record count, and pinned state.")
.RequireAuthorization("Concelier.Advisories.Read");
snapshots.MapPost("/{snapshotId}/download", DownloadSnapshot)
.WithName("DownloadFeedSnapshot")
.WithSummary("Download a feed snapshot")
.WithDescription("Initiates or polls the download of a specific feed snapshot bundle. Returns download progress including bytes downloaded, total size, and completion percentage.")
.RequireAuthorization("Concelier.Advisories.Read");
snapshots.MapPatch("/{snapshotId}", PinSnapshot)
.WithName("PinFeedSnapshot")
.WithSummary("Pin or unpin a feed snapshot")
.WithDescription("Sets the pinned state of a feed snapshot, protecting it from automatic retention-based pruning when pinned. Pinned snapshots are retained indefinitely regardless of the mirror's retention policy.")
.RequireAuthorization("Concelier.Advisories.Ingest");
snapshots.MapDelete("/{snapshotId}", DeleteSnapshot)
.WithName("DeleteFeedSnapshot")
.WithSummary("Delete a feed snapshot")
.WithDescription("Permanently removes a feed snapshot and its associated bundle files from storage. Pinned snapshots should be unpinned before deletion. Returns 404 if the snapshot does not exist.")
.RequireAuthorization("Concelier.Advisories.Ingest");
// Bundle management
var bundles = app.MapGroup("/api/v1/concelier/bundles")
.WithTags("AirGapBundles");
.WithTags("AirGapBundles")
.RequireAuthorization("Concelier.Advisories.Read")
.RequireTenant();
bundles.MapGet(string.Empty, ListBundles);
bundles.MapGet("/{bundleId}", GetBundle);
bundles.MapPost(string.Empty, CreateBundle);
bundles.MapDelete("/{bundleId}", DeleteBundle);
bundles.MapPost("/{bundleId}/download", DownloadBundle);
bundles.MapGet(string.Empty, ListBundles)
.WithName("ListAirGapBundles")
.WithSummary("List air-gap bundles")
.WithDescription("Returns all air-gap advisory bundles with their status, included feeds, snapshot IDs, feed versions, size, and checksums. Bundles in 'ready' status include download and manifest URLs.")
.RequireAuthorization("Concelier.Advisories.Read");
bundles.MapGet("/{bundleId}", GetBundle)
.WithName("GetAirGapBundle")
.WithSummary("Get air-gap bundle details")
.WithDescription("Returns the full record for a specific air-gap bundle identified by bundle ID, including status, included feeds, snapshot references, feed version map, size, checksums, and download URLs.")
.RequireAuthorization("Concelier.Advisories.Read");
bundles.MapPost(string.Empty, CreateBundle)
.WithName("CreateAirGapBundle")
.WithSummary("Create a new air-gap bundle")
.WithDescription("Creates a new air-gap advisory bundle aggregating the specified feeds and snapshots. The bundle starts in 'pending' status and transitions to 'ready' once the packaging job completes.")
.RequireAuthorization("Concelier.Advisories.Ingest");
bundles.MapDelete("/{bundleId}", DeleteBundle)
.WithName("DeleteAirGapBundle")
.WithSummary("Delete an air-gap bundle")
.WithDescription("Permanently removes an air-gap bundle and its associated package files. Returns 404 if the bundle does not exist. Active downloads of the bundle may fail after deletion.")
.RequireAuthorization("Concelier.Advisories.Ingest");
bundles.MapPost("/{bundleId}/download", DownloadBundle)
.WithName("DownloadAirGapBundle")
.WithSummary("Download an air-gap bundle")
.WithDescription("Initiates or polls the download of a specific air-gap bundle. Returns progress including bytes downloaded, total size, and completion percentage for use by offline deployment tooling.")
.RequireAuthorization("Concelier.Advisories.Read");
// Import operations
var imports = app.MapGroup("/api/v1/concelier/imports")
.WithTags("AirGapImports");
.WithTags("AirGapImports")
.RequireAuthorization("Concelier.Advisories.Ingest")
.RequireTenant();
imports.MapPost("/validate", ValidateImport);
imports.MapPost("/", StartImport);
imports.MapGet("/{importId}", GetImportProgress);
imports.MapPost("/validate", ValidateImport)
.WithName("ValidateAirGapImport")
.WithSummary("Validate an air-gap import bundle before importing")
.WithDescription("Validates an air-gap bundle before importing by checking checksums, signature, and manifest integrity. Returns the list of feeds and snapshots found, total record count, any validation errors, and warnings. The canImport flag indicates whether the bundle is safe to import.")
.RequireAuthorization("Concelier.Advisories.Ingest");
imports.MapPost("/", StartImport)
.WithName("StartAirGapImport")
.WithSummary("Start an air-gap bundle import")
.WithDescription("Initiates the import of a previously validated air-gap bundle into the advisory database. Returns an import ID that can be polled via the progress endpoint. Import processes feeds sequentially and updates all affected interest scores on completion.")
.RequireAuthorization("Concelier.Advisories.Ingest");
imports.MapGet("/{importId}", GetImportProgress)
.WithName("GetAirGapImportProgress")
.WithSummary("Get air-gap import progress")
.WithDescription("Returns the current progress of an air-gap import operation identified by import ID, including current feed being processed, feeds completed, records imported, and overall completion percentage.")
.RequireAuthorization("Concelier.Advisories.Read");
// Version lock operations
var versionLocks = app.MapGroup("/api/v1/concelier/version-locks")
.WithTags("VersionLocks");
.WithTags("VersionLocks")
.RequireAuthorization("Concelier.Advisories.Read")
.RequireTenant();
versionLocks.MapGet(string.Empty, ListVersionLocks);
versionLocks.MapGet("/{feedType}", GetVersionLock);
versionLocks.MapPut("/{feedType}", SetVersionLock);
versionLocks.MapDelete("/{lockId}", RemoveVersionLock);
versionLocks.MapGet(string.Empty, ListVersionLocks)
.WithName("ListVersionLocks")
.WithSummary("List all version locks")
.WithDescription("Returns all active feed version locks with their lock mode (pinned, latest, date-locked), pinned snapshot or version, and the operator who created the lock. Version locks prevent automatic updates to pinned feeds.")
.RequireAuthorization("Concelier.Advisories.Read");
versionLocks.MapGet("/{feedType}", GetVersionLock)
.WithName("GetVersionLock")
.WithSummary("Get version lock for a feed type")
.WithDescription("Returns the current version lock for the specified feed type, or null if no lock exists. Includes the lock mode, pinned version or snapshot reference, and creation metadata.")
.RequireAuthorization("Concelier.Advisories.Read");
versionLocks.MapPut("/{feedType}", SetVersionLock)
.WithName("SetVersionLock")
.WithSummary("Set or update a version lock for a feed type")
.WithDescription("Creates or replaces the version lock for the specified feed type. Supports pinned-version, pinned-snapshot, date-locked, and latest-always modes. Active locks prevent the mirror sync scheduler from advancing the feed beyond the locked point.")
.RequireAuthorization("Concelier.Advisories.Ingest");
versionLocks.MapDelete("/{lockId}", RemoveVersionLock)
.WithName("RemoveVersionLock")
.WithSummary("Remove a version lock")
.WithDescription("Removes an existing version lock by lock ID, allowing the mirror sync scheduler to resume automatic feed updates for the associated feed type. Returns 404 if the lock does not exist.")
.RequireAuthorization("Concelier.Advisories.Ingest");
// Offline status
app.MapGet("/api/v1/concelier/offline-status", GetOfflineSyncStatus)
.WithTags("OfflineStatus");
.WithTags("OfflineStatus")
.WithName("GetOfflineSyncStatus")
.WithSummary("Get offline/air-gap sync status")
.WithDescription("Returns the current offline synchronization state across all feed mirrors, including per-feed record counts, staleness indicators, total storage usage, and actionable recommendations for feeds that require attention.")
.RequireAuthorization("Concelier.Advisories.Read")
.RequireTenant();
}
// ---- Mirror Handlers ----

View File

@@ -12,6 +12,7 @@ using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Results;
using StellaOps.Replay.Core.FeedSnapshot;
@@ -33,49 +34,57 @@ internal static class FeedSnapshotEndpointExtensions
{
var group = app.MapGroup("/api/v1/feeds/snapshot")
.WithTags("FeedSnapshot")
.WithOpenApi();
.WithOpenApi()
.RequireTenant();
// POST /api/v1/feeds/snapshot - Create atomic snapshot
group.MapPost("/", CreateSnapshotAsync)
.WithName("CreateFeedSnapshot")
.WithSummary("Create an atomic feed snapshot")
.WithDescription("Creates an atomic snapshot of all registered feed sources with a composite digest.");
.WithDescription("Creates an atomic, point-in-time snapshot of all registered feed sources, computing per-source digests and a composite digest for deterministic replay and offline-first bundle generation. Optionally scoped to a subset of sources via the sources field.")
.RequireAuthorization("Concelier.Advisories.Ingest");
// GET /api/v1/feeds/snapshot - List available snapshots
group.MapGet("/", ListSnapshotsAsync)
.WithName("ListFeedSnapshots")
.WithSummary("List available feed snapshots")
.WithDescription("Returns a list of available feed snapshots with metadata.");
.WithDescription("Returns a paginated list of available feed snapshots ordered by creation time, including composite digest, label, source count, and total item count per snapshot.")
.RequireAuthorization("Concelier.Advisories.Read");
// GET /api/v1/feeds/snapshot/{snapshotId} - Get snapshot details
group.MapGet("/{snapshotId}", GetSnapshotAsync)
.WithName("GetFeedSnapshot")
.WithSummary("Get feed snapshot details")
.WithDescription("Returns detailed information about a specific feed snapshot.");
.WithDescription("Returns the full details of a specific feed snapshot identified by its snapshot ID, including per-source digests, item counts, and creation timestamps.")
.RequireAuthorization("Concelier.Advisories.Read");
// GET /api/v1/feeds/snapshot/{snapshotId}/export - Export snapshot bundle
group.MapGet("/{snapshotId}/export", ExportSnapshotAsync)
.WithName("ExportFeedSnapshot")
.WithSummary("Export feed snapshot bundle")
.WithDescription("Downloads the snapshot bundle as a compressed archive for offline use.");
.WithDescription("Streams the snapshot bundle as a compressed archive (zstd by default, gzip or uncompressed via the format query parameter) for offline and air-gap use. Includes manifest and checksum files.")
.RequireAuthorization("Concelier.Advisories.Read");
// POST /api/v1/feeds/snapshot/import - Import snapshot bundle
group.MapPost("/import", ImportSnapshotAsync)
.WithName("ImportFeedSnapshot")
.WithSummary("Import feed snapshot bundle")
.WithDescription("Imports a snapshot bundle from a compressed archive.");
.WithDescription("Imports a snapshot bundle from a compressed archive uploaded as a multipart file, optionally validating per-source digests before registering the snapshot. The resulting snapshot ID is returned in the Location header.")
.RequireAuthorization("Concelier.Advisories.Ingest");
// GET /api/v1/feeds/snapshot/{snapshotId}/validate - Validate snapshot
group.MapGet("/{snapshotId}/validate", ValidateSnapshotAsync)
.WithName("ValidateFeedSnapshot")
.WithSummary("Validate feed snapshot integrity")
.WithDescription("Validates the integrity of a feed snapshot against current feed state.");
.WithDescription("Validates a stored feed snapshot against the current live feed state, detecting source-level digest drift. Returns which sources have drifted and the item-level add/remove/modify counts for each.")
.RequireAuthorization("Concelier.Advisories.Read");
// GET /api/v1/feeds/sources - List registered feed sources
group.MapGet("/sources", ListSourcesAsync)
.WithName("ListFeedSources")
.WithSummary("List registered feed sources")
.WithDescription("Returns a list of registered feed sources available for snapshots.");
.WithDescription("Returns the identifiers of all feed sources currently registered with the snapshot coordinator and eligible for inclusion in new snapshots.")
.RequireAuthorization("Concelier.Advisories.Read");
}
private static async Task<IResult> CreateSnapshotAsync(

View File

@@ -8,6 +8,7 @@
using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Concelier.Interest;
using StellaOps.Concelier.Interest.Models;
@@ -24,7 +25,8 @@ internal static class InterestScoreEndpointExtensions
public static void MapInterestScoreEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1")
.WithTags("Interest Scores");
.WithTags("Interest Scores")
.RequireTenant();
// GET /api/v1/canonical/{id}/score - Get interest score for a canonical advisory
group.MapGet("/canonical/{id:guid}/score", async (
@@ -40,8 +42,10 @@ internal static class InterestScoreEndpointExtensions
})
.WithName("GetInterestScore")
.WithSummary("Get interest score for a canonical advisory")
.WithDescription("Returns the current interest score and tier for a canonical advisory, including the scored reasons and the last build in which the advisory's affected component was observed. Returns 404 if no score has been computed yet.")
.Produces<InterestScoreResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScoreReadPolicy);
// GET /api/v1/scores - Query interest scores
group.MapGet("/scores", async (
@@ -77,7 +81,9 @@ internal static class InterestScoreEndpointExtensions
})
.WithName("QueryInterestScores")
.WithSummary("Query interest scores with optional filtering")
.Produces<InterestScoreListResponse>(StatusCodes.Status200OK);
.WithDescription("Returns a paginated list of canonical advisory interest scores, optionally filtered by minimum and maximum score thresholds. Results include score tier, contributing reasons, and computation timestamp.")
.Produces<InterestScoreListResponse>(StatusCodes.Status200OK)
.RequireAuthorization(ScoreReadPolicy);
// GET /api/v1/scores/distribution - Get score distribution statistics
group.MapGet("/scores/distribution", async (
@@ -99,7 +105,9 @@ internal static class InterestScoreEndpointExtensions
})
.WithName("GetScoreDistribution")
.WithSummary("Get score distribution statistics")
.Produces<ScoreDistributionResponse>(StatusCodes.Status200OK);
.WithDescription("Returns aggregate statistics across all computed interest scores, including counts per tier (high/medium/low/none), total scored advisories, average score, and median score. Used for dashboarding and SLA reporting.")
.Produces<ScoreDistributionResponse>(StatusCodes.Status200OK)
.RequireAuthorization(ScoreReadPolicy);
// POST /api/v1/canonical/{id}/score/compute - Compute score for a canonical
group.MapPost("/canonical/{id:guid}/score/compute", async (
@@ -114,7 +122,9 @@ internal static class InterestScoreEndpointExtensions
})
.WithName("ComputeInterestScore")
.WithSummary("Compute and update interest score for a canonical advisory")
.Produces<InterestScoreResponse>(StatusCodes.Status200OK);
.WithDescription("Triggers an on-demand interest score computation for a single canonical advisory and persists the result. Useful for forcing a score refresh after SBOM registration, reachability updates, or manual investigation.")
.Produces<InterestScoreResponse>(StatusCodes.Status200OK)
.RequireAuthorization(ScoreAdminPolicy);
// POST /api/v1/scores/recalculate - Admin endpoint to trigger full recalculation
group.MapPost("/scores/recalculate", async (
@@ -144,7 +154,9 @@ internal static class InterestScoreEndpointExtensions
})
.WithName("RecalculateScores")
.WithSummary("Trigger interest score recalculation (full or batch)")
.Produces<RecalculateResponse>(StatusCodes.Status202Accepted);
.WithDescription("Enqueues an interest score recalculation for either a specific set of canonical IDs (batch mode) or all advisories (full mode). Returns 202 Accepted immediately; actual updates occur asynchronously in the scoring background job.")
.Produces<RecalculateResponse>(StatusCodes.Status202Accepted)
.RequireAuthorization(ScoreAdminPolicy);
// POST /api/v1/scores/degrade - Admin endpoint to run stub degradation
group.MapPost("/scores/degrade", async (
@@ -167,7 +179,9 @@ internal static class InterestScoreEndpointExtensions
})
.WithName("DegradeToStubs")
.WithSummary("Degrade low-interest advisories to stubs")
.Produces<DegradeResponse>(StatusCodes.Status200OK);
.WithDescription("Downgrades all canonical advisories whose interest score falls below the specified threshold (or the configured default) to stub representation, reducing storage footprint. Returns the count of advisories degraded.")
.Produces<DegradeResponse>(StatusCodes.Status200OK)
.RequireAuthorization(ScoreAdminPolicy);
// POST /api/v1/scores/restore - Admin endpoint to restore stubs
group.MapPost("/scores/restore", async (
@@ -190,7 +204,9 @@ internal static class InterestScoreEndpointExtensions
})
.WithName("RestoreFromStubs")
.WithSummary("Restore stubs with increased interest scores")
.Produces<RestoreResponse>(StatusCodes.Status200OK);
.WithDescription("Promotes stub advisories whose interest score now exceeds the specified restoration threshold back to full canonical representation. Typically triggered after new SBOM registrations or reachability discoveries raise scores above the stub cutoff.")
.Produces<RestoreResponse>(StatusCodes.Status200OK)
.RequireAuthorization(ScoreAdminPolicy);
}
private static InterestScoreResponse MapToResponse(InterestScore score) => new()

View File

@@ -3,6 +3,7 @@ using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Concelier.WebService.Diagnostics;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Results;
@@ -49,7 +50,11 @@ internal static class MirrorEndpointExtensions
}
return await WriteFileAsync(context, path, "application/json").ConfigureAwait(false);
});
})
.WithName("GetMirrorIndex")
.WithSummary("Get mirror index")
.WithDescription("Serves the mirror index JSON file listing all available advisory export files. Respects per-mirror authentication settings and rate limits. Clients should poll this endpoint to discover available export bundles.")
.RequireTenant();
app.MapGet("/concelier/exports/{**relativePath}", async (
string? relativePath,
@@ -91,7 +96,11 @@ internal static class MirrorEndpointExtensions
var contentType = ResolveContentType(path);
return await WriteFileAsync(context, path, contentType).ConfigureAwait(false);
});
})
.WithName("DownloadMirrorFile")
.WithSummary("Download a mirror export file")
.WithDescription("Serves a specific advisory export file from the mirror by relative path. Content-Type is resolved from the file extension (JSON, JWS, or octet-stream). Respects per-domain authentication and download rate limits.")
.RequireTenant();
}
private static ConcelierOptions.MirrorDomainOptions? FindDomain(ConcelierOptions.MirrorOptions mirrorOptions, string? domainId)

View File

@@ -8,6 +8,7 @@
using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Concelier.SbomIntegration;
using StellaOps.Concelier.SbomIntegration.Models;
@@ -21,7 +22,8 @@ internal static class SbomEndpointExtensions
public static void MapSbomEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1")
.WithTags("SBOM Learning");
.WithTags("SBOM Learning")
.RequireTenant();
// POST /api/v1/learn/sbom - Register and learn from an SBOM
group.MapPost("/learn/sbom", async (
@@ -57,8 +59,10 @@ internal static class SbomEndpointExtensions
})
.WithName("LearnSbom")
.WithSummary("Register SBOM and update interest scores for affected advisories")
.WithDescription("Registers an SBOM by digest, extracts its component PURLs, matches them against the canonical advisory database, and updates the interest score for every matched advisory. Accepts optional reachability and deployment maps to weight reachable/deployed components more heavily.")
.Produces<SbomLearnResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization("Concelier.Advisories.Ingest");
// GET /api/v1/sboms/{digest}/affected - Get advisories affecting an SBOM
group.MapGet("/sboms/{digest}/affected", async (
@@ -96,8 +100,10 @@ internal static class SbomEndpointExtensions
})
.WithName("GetSbomAffected")
.WithSummary("Get advisories affecting an SBOM")
.WithDescription("Returns all canonical advisories that matched components in the specified SBOM, identified by digest. Each match includes the PURL, reachability and deployment status, confidence score, and the matching method used.")
.Produces<SbomAffectedResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization("Concelier.Advisories.Read");
// GET /api/v1/sboms - List registered SBOMs
group.MapGet("/sboms", async (
@@ -136,7 +142,9 @@ internal static class SbomEndpointExtensions
})
.WithName("ListSboms")
.WithSummary("List registered SBOMs with pagination")
.Produces<SbomListResponse>(StatusCodes.Status200OK);
.WithDescription("Returns a paginated list of all registered SBOMs with summary information including format, component count, affected advisory count, and last match timestamp. Optionally filtered by tenant ID.")
.Produces<SbomListResponse>(StatusCodes.Status200OK)
.RequireAuthorization("Concelier.Advisories.Read");
// GET /api/v1/sboms/{digest} - Get SBOM registration details
group.MapGet("/sboms/{digest}", async (
@@ -169,8 +177,10 @@ internal static class SbomEndpointExtensions
})
.WithName("GetSbom")
.WithSummary("Get SBOM registration details")
.WithDescription("Returns the full registration record for an SBOM identified by its digest, including format, spec version, component count, source, tenant, and last advisory match timestamp.")
.Produces<SbomDetailResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization("Concelier.Advisories.Read");
// DELETE /api/v1/sboms/{digest} - Unregister an SBOM
group.MapDelete("/sboms/{digest}", async (
@@ -183,7 +193,9 @@ internal static class SbomEndpointExtensions
})
.WithName("UnregisterSbom")
.WithSummary("Unregister an SBOM")
.Produces(StatusCodes.Status204NoContent);
.WithDescription("Removes the SBOM registration identified by digest from the registry, along with all associated PURL-to-canonical match records. Does not modify the interest scores of previously matched advisories.")
.Produces(StatusCodes.Status204NoContent)
.RequireAuthorization("Concelier.Advisories.Ingest");
// POST /api/v1/sboms/{digest}/rematch - Rematch SBOM against current advisories
group.MapPost("/sboms/{digest}/rematch", async (
@@ -210,8 +222,10 @@ internal static class SbomEndpointExtensions
})
.WithName("RematchSbom")
.WithSummary("Re-match SBOM against current advisory database")
.WithDescription("Re-runs PURL matching for an existing SBOM against the current state of the canonical advisory database and updates match records. Returns the previous and new affected advisory counts so callers can detect newly introduced vulnerabilities.")
.Produces<SbomRematchResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization("Concelier.Advisories.Ingest");
// PATCH /api/v1/sboms/{digest} - Incrementally update SBOM (add/remove components)
group.MapPatch("/sboms/{digest}", async (
@@ -253,8 +267,10 @@ internal static class SbomEndpointExtensions
})
.WithName("UpdateSbomDelta")
.WithSummary("Incrementally update SBOM components (add/remove)")
.WithDescription("Applies an incremental delta to a registered SBOM, adding or removing component PURLs and updating the reachability and deployment maps. After the update, re-runs advisory matching and interest score updates only for the affected components. Supports full replacement mode.")
.Produces<SbomDeltaResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization("Concelier.Advisories.Ingest");
// GET /api/v1/sboms/stats - Get SBOM registry statistics
group.MapGet("/sboms/stats", async (
@@ -275,7 +291,9 @@ internal static class SbomEndpointExtensions
})
.WithName("GetSbomStats")
.WithSummary("Get SBOM registry statistics")
.Produces<SbomStatsResponse>(StatusCodes.Status200OK);
.WithDescription("Returns aggregate statistics for the SBOM registry, including total registered SBOMs, total unique PURLs, total advisory matches, number of SBOMs with at least one match, and average matches per SBOM. Optionally scoped to a specific tenant.")
.Produces<SbomStatsResponse>(StatusCodes.Status200OK)
.RequireAuthorization("Concelier.Advisories.Read");
}
private static SbomFormat ParseSbomFormat(string? format)

View File

@@ -22,6 +22,7 @@ using StellaOps.Aoc.AspNetCore.Routing;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Core.Attestation;
using StellaOps.Concelier.Core.Diagnostics;
@@ -83,6 +84,10 @@ public partial class Program
private const string AdvisoryIngestPolicyName = "Concelier.Advisories.Ingest";
private const string AdvisoryReadPolicyName = "Concelier.Advisories.Read";
private const string AocVerifyPolicyName = "Concelier.Aoc.Verify";
private const string CanonicalReadPolicyName = "Concelier.Canonical.Read";
private const string CanonicalIngestPolicyName = "Concelier.Canonical.Ingest";
private const string InterestReadPolicyName = "Concelier.Interest.Read";
private const string InterestAdminPolicyName = "Concelier.Interest.Admin";
public const string TenantHeaderName = "X-Stella-Tenant";
public static async Task Main(string[] args)
@@ -824,6 +829,10 @@ builder.Services.AddAuthorization(options =>
options.AddStellaOpsScopePolicy(AdvisoryIngestPolicyName, StellaOpsScopes.AdvisoryIngest);
options.AddStellaOpsScopePolicy(AdvisoryReadPolicyName, StellaOpsScopes.AdvisoryRead);
options.AddStellaOpsScopePolicy(AocVerifyPolicyName, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.AocVerify);
options.AddStellaOpsScopePolicy(CanonicalReadPolicyName, StellaOpsScopes.AdvisoryRead);
options.AddStellaOpsScopePolicy(CanonicalIngestPolicyName, StellaOpsScopes.AdvisoryIngest);
options.AddStellaOpsScopePolicy(InterestReadPolicyName, StellaOpsScopes.VulnView);
options.AddStellaOpsScopePolicy(InterestAdminPolicyName, StellaOpsScopes.AdvisoryIngest);
});
var pluginHostOptions = BuildPluginOptions(concelierOptions, builder.Environment.ContentRootPath);
@@ -831,6 +840,7 @@ builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.Services.AddStellaOpsTenantServices();
builder.TryAddStellaOpsLocalBinding("concelier");
var app = builder.Build();
@@ -898,6 +908,7 @@ if (authorityConfigured)
});
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();
}
// Stella Router integration
@@ -1019,7 +1030,7 @@ if (swaggerEnabled)
var orchestratorGroup = app.MapGroup("/internal/orch");
if (authorityConfigured)
{
orchestratorGroup.RequireAuthorization();
orchestratorGroup.RequireAuthorization(JobsPolicyName);
}
orchestratorGroup.MapPost("/registry", async (