SbomService — audit-bundle SBOM endpoint
Status: implemented — STORAGE + ENDPOINT-A shipped in
docs-archive/implplan/SPRINT_20260610_003_SbomService_audit_bundle_sbom_endpoint.md; ENDPOINT-B now proxies Scanner’s artifact vulnerability report contract throughdocs/implplan/SPRINT_20260612_001_SbomService_vuln_audit_proxy_enablement.md(SBOM-VULN-PROXY-001). Consumer contract:docs/implplan/HANDOFF_SbomService_audit_bundle_endpoints.md.
Why
ExportCenter audit bundles (POST /api/v1/audit-bundle, downloaded as a ZIP) include, per subject (an artifact), an sbom/cyclonedx.json entry and a reports/vuln-report.json entry. The SBOM/vuln entries used to be fabricated (hard-coded CVE-2023-12345 / empty CycloneDX shell) — hollow-impl finding #4, removed in commit 6063a14dda. ExportCenter now fetches real data through ISbomAuditSource/IVulnAuditSource, which return null (so the bundle omits the section, never fakes it) until SbomService exposes the endpoints below.
Hard rule: when no data exists, the source returns null and the bundle omits the section. Never return placeholder/sample data.
Endpoint A — raw CycloneDX by subject (real)
GET /api/v1/sbom/subject/{subjectRef}/cyclonedx?version={versionId|latest}
Auth: Bearer token or Router-signed identity envelope
Required scope: sbom:read
subjectRef(route, URL-encoded by caller, decoded withUri.UnescapeDataString) is the artifact ref — the same value space as the existingGET /api/v1/sbom/uploads/{artifactRef}.version(optional, defaultlatest) selects a specificversion_id;latest= highestsequence_number.- 200 OK —
Content-Type: application/jsonby default, orapplication/vnd.cyclonedx+jsonwhen the caller sendsAccept: application/vnd.cyclonedx+json; body = the raw CycloneDX document VERBATIM (the exact bytes the SbomService received; not re-encoded, not double-escaped). The handler returns the storeddocumentTEXT as-is viaResults.Text(document, contentType)— no JSON serializer round-trip. - 404 — no SBOM for that subject/tenant. This is the expected common case (ExportCenter omits the SBOM section); it must NOT 500 and must NOT fabricate.
- 400 — blank/malformed
subjectRef. 401 — missing/invalid authentication. 403 — missingsbom:read.
The handler resolves the ledger version in two steps (no single SQL join — the document lives in a separate sidecar repository): ISbomLedgerRepository.GetTenantScopedVersionAsync(artifactRef, tenantId, versionId?) returns the tenant-scoped version row, then ISbomDocumentRepository.GetDocumentByDigestAsync(version.Digest) fetches the verbatim document. The resolver’s SQL is the tenant-scoped equivalent of the conceptual join:
-- GetTenantScopedVersionAsync (latest; ?version=<id> adds AND version_id = @version_id)
SELECT ... , tenant_id
FROM sbom.ledger_versions
WHERE artifact_ref = @ref AND tenant_id = @tenant
ORDER BY sequence_number DESC, created_at_utc DESC
LIMIT 1;
-- then: SELECT document, media_type FROM sbom.sbom_documents WHERE digest = @digest
Tenant isolation (security-critical)
The pre-existing upload/version-history path had no tenant scoping: sbom.ledger_versions had no tenant_id column and repository lookups filtered only on artifact/version/digest. The authenticated-boundary sprint closes that gap across uploads, timelines, ledger reads, diffs, lineage, and change traces:
- Migration
004_ledger_versions_tenant_id.sqladdstenant_id TEXT; migration005_ledger_versions_tenant_chain.sqlreplaces the global artifact/sequence uniqueness rule with tenant-local chain sequencing. - The upload path captures the canonical authenticated
stellaops:tenantclaim and threads it throughSbomUploadService.UploadAsync(request, tenantId, ct)→SbomLedgerSubmission.TenantId→SbomLedgerVersion.TenantId→ theledger_versions.tenant_idcolumn. - All public ledger and lineage lookups use tenant-aware repository methods. A row with a NULL
tenant_id(legacy upload before this column existed) never matches a specific tenant — the safe default (no back-fill, no leak); such rows simply 404.
A subject uploaded under tenant A returns 404 when queried with a valid tenant B identity (proven by SbomSubjectCycloneDxEndpointTests.Get_CrossTenant_Returns404). Raw tenant headers cannot switch identity. Shipping the endpoint without this boundary would be a cross-tenant SBOM leak.
Same-named artifacts in different tenants receive independent chain IDs and sequence numbers; cross-tenant parent version/digest references are rejected.
Endpoint B - vuln/scan report by subject (Scanner proxy)
GET /api/v1/scan/results?subject={subjectRef}&digest={optionalDigest}
Auth: Bearer token or Router-signed identity envelope
Required scope: sbom:read
SbomService keeps the ExportCenter-facing Endpoint B URL stable and proxies to Scanner’s canonical artifact vulnerability report endpoint:
GET /api/v1/reports/artifact-vulnerabilities?subject={artifactRefOrDigest}&digest={optionalDigest}
Headers: x-tenant-id: <tenant>
Behavior:
- 200 OK - body is the raw Scanner report JSON returned by Scanner, forwarded without DTO reshaping, synthetic findings, or schema translation. The report schema is
stellaops.scanner.artifact-vulnerability-report.v1and the source isscanner.public-reachability-verdicts. - 404 - Scanner 404, Scanner 204/empty, unknown artifacts, cross-tenant artifacts, or artifacts with no persisted reachability verdicts map to SbomService 404. ExportCenter must treat this as
nulland omitreports/vuln-report.json. - 400 - blank
subjecton the SbomService request. Scanner validation errors are forwarded as upstream non-success responses with Scanner’s body when present. - 503 - Scanner base URL is rejected by
SbomService:ScannerHttpallowlists, the Scanner request times out, or Scanner is unreachable. This is a non-placeholder problem response, never a fabricated vulnerability report.
Tenant propagation is load-bearing: SbomService reads only the authenticated stellaops:tenant claim. Its current Scanner compatibility client forwards that trusted tenant through Scanner’s internal x-tenant-id contract; any actor value originates from the authenticated principal, not the incoming raw headers. A tenant B identity for a tenant A artifact therefore reaches Scanner as tenant B and must return 404/null.
Configuration:
SbomService:ScannerUrlselects the Scanner base URL (default local dev value ishttp://localhost:5100).SbomService:ScannerHttp:Timeout,AllowedHosts, andAllowedSchemesgate outbound Scanner calls.
The Scanner upstream contract is:
GET /api/v1/reports/artifact-vulnerabilities?subject={artifactRefOrDigest}&digest={optionalDigest}
Tenant: authenticated Scanner tenant context
Auth: scanner.reports.read or scanner:read
Scanner resolves the latest tenant-scoped scanner.scan_runtime_state row for the artifact ref/digest and returns schema stellaops.scanner.artifact-vulnerability-report.v1 from source scanner.public-reachability-verdicts. Unknown artifacts, cross-tenant artifacts, scans without persisted reachability verdicts, and unavailable reachability storage return 404/null rather than fabricated JSON. The exact Scanner contract is documented in docs/modules/scanner/architecture.md.
Follow-up enablement is tracked in docs/implplan/SPRINT_20260612_001_SbomService_vuln_audit_proxy_enablement.md: SbomService SBOM-VULN-PROXY-001 is the proxy proof; ExportCenter EXPORT-VULN-AUDIT-002 consumes this endpoint and proves bundle inclusion/omission behavior.
Storage model — sbom.sbom_documents sidecar
The raw uploaded CycloneDX was ephemeral (parsed in-memory for digest + Concelier-forward, then discarded). It is now persisted in a digest-keyed sidecar table:
CREATE TABLE IF NOT EXISTS sbom.sbom_documents (
digest TEXT PRIMARY KEY,
media_type TEXT NOT NULL,
document TEXT NOT NULL, -- raw GetRawText() bytes; TEXT, never jsonb
byte_length INTEGER NOT NULL,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Design rationale:
- Sidecar, not a column on
sbom.ledger_versions— the ledger is a hot table (4 indexes, read on every version-history/diff/lineage/retention query). A multi-MB document column would bloat every row read. The sidecar is touched only by Endpoint A. - Not the
IContentAddressedBlobStore— that store is only conditionally registered (whenSbomService:LineageExport:StorageRootPathis set); depending on it would make Endpoint A fail on deployments that haven’t configured it. A first-class DB table converges unconditionally on startup. document TEXT, neverjsonb—jsonbre-normalizes whitespace and re-orders keys, breaking the byte-for-byte contract (and any signature/attestation over the raw bytes). The raw text is stored and returned verbatim.- Content-addressed dedup — keyed on
digest(the value the upload already computes, identical toledger_versions.digest), written withINSERT … ON CONFLICT (digest) DO NOTHING. Identical re-uploads share one document row.
The document is persisted at upload time before AddVersionAsync(...) writes the ledger row (in SbomUploadService.UploadAsync, right after the digest is computed), so a version row never points at a missing sidecar document. The repository contract is ISbomDocumentRepository.UpsertDocumentAsync / GetDocumentByDigestAsync (Postgres impl PostgresSbomDocumentRepository, in-memory impl InMemorySbomDocumentRepository for the local/testing harness), wired in Program.cs alongside the ledger repository (durable + LocalHarness branches).
Digest vs. verbatim text (implementation note). The digest is computed from the canonical compact serialization of the document (SbomUploadService.ComputeDigest → JsonSerializer.SerializeToUtf8Bytes(..., WriteIndented:false)), while the stored document is the verbatim JsonElement.GetRawText() the upload had in hand. These can differ in whitespace/formatting while sharing a digest, so dedup is keyed on canonical content: two uploads with the same logical SBOM but different whitespace produce one digest and one sidecar row (the first writer’s verbatim text wins). What “verbatim / byte-for-byte” means here is relative to the bytes the SbomService received (its server-side GetRawText()), which is exactly what Endpoint A returns — there is no re-encoding between store and read. byte_length is the UTF-8 byte count of the stored verbatim text.
Migration & auto-apply (AGENTS.md §2.7)
Three forward-only, idempotent embedded migrations under src/SbomService/__Libraries/StellaOps.SbomService.Persistence/Migrations/, picked up by the existing glob (Migrations\**\*.sql, excludes _archived) and auto-applied via AddStartupMigrations(...) — they converge on a fresh DB with no manual psql:
003_sbom_documents_sidecar.sql—CREATE TABLE IF NOT EXISTS sbom.sbom_documents. Mirrored intoPostgresSbomDocumentRepository.EnsureTablesAsync(defensive DDL).004_ledger_versions_tenant_id.sql—ALTER TABLE sbom.ledger_versions ADD COLUMN IF NOT EXISTS tenant_id TEXT+ the covering index. Mirrored intoPostgresSbomLedgerRepository.EnsureTablesAsync(defensiveADD COLUMN IF NOT EXISTS) so the second DDL path stays in sync.005_ledger_versions_tenant_chain.sql— replaces global(artifact_ref, sequence_number)uniqueness with tenant-local(artifact_ref, tenant_id, sequence_number)uniqueness for non-legacy rows.
Confirmed in Testcontainers: the e2e tests boot the host on a fresh postgres:16-alpine and the migration runner logs Applied 5 migration(s) before any request.
Runbook — verify a subject’s SBOM round-trips
- Upload a CycloneDX SBOM:
curl -sS -X POST "$SBOM/api/v1/sbom/upload" \ -H "Authorization: Bearer $TENANT_A_TOKEN" -H "content-type: application/json" \ -d '{"artifactRef":"registry.io/app@sha256:...","sbom":{ "bomFormat":"CycloneDX","specVersion":"1.6", ... }}' - Fetch it back and compare bytes:
curl -sS "$SBOM/api/v1/sbom/subject/registry.io%2Fapp%40sha256:.../cyclonedx" \ -H "Authorization: Bearer $TENANT_A_TOKEN" -o roundtrip.json # roundtrip.json must be byte-for-byte identical to the uploaded `sbom` object's raw text - Negative checks:
- Unknown ref ⇒
404. - Same ref with a valid tenant B token ⇒
404(tenant isolation). GET /api/v1/scan/results?subject=unknownwith Scanner reachable ⇒404(no report; ExportCenter omits).GET /api/v1/scan/results?subject=...with Scanner unavailable or rejected by outbound allowlists ⇒503problem JSON (truthful outage; never placeholder report JSON).
- Unknown ref ⇒
ExportCenter coordination
Once Endpoint A is live:
- Set ExportCenter config:
{ "ExportCenter": { "SbomAudit": { "BaseUrl": "http://sbomservice.stella-ops.local", "TimeoutSeconds": 30 } } } - ExportCenter’s
HttpSbomAuditSourceacquires a tenant-scoped client-credentials token withsbom:read, caches it by tenant and scope, and callsGET {BaseUrl}/api/v1/sbom/subject/{urlencoded subjectRef}/cyclonedxwithAuthorization: Bearer .... Token failure omits the section without sending a raw tenant header. - Configure
ExportCenter:VulnAudit:BaseUrlto the same SbomService base URL when vulnerability report sections should be included.HttpVulnAuditSourcereuses the tenant-scopedsbom:readbearer-token provider, callsGET {BaseUrl}/api/v1/scan/results?subject={urlencoded subjectRef}without raw tenant headers, includesreports/vuln-report.jsonon 200, and omits the entry on token failure or 404/null.
(Steps 1–3 are owned by the ExportCenter side, not SbomService.)
Deploy to the live stack — MANUAL operator step (requires go-signal)
This repository change does not itself deploy or recreate stellaops-sbomservice. Before rollout, verify the running image digest and migration ledger rather than assuming the local stack is current. Rolling this out is an explicit, manual operator action; perform these steps only with an operator go-signal:
- Build the SbomService image from the committed source (the normal CI/devops image build for
stellaops-sbomservice; e.g. the service’sdevops/build path). Pin the resulting digest. - Recreate the container so the new image (and therefore the new endpoints + embedded migrations) is live:
On startupdocker compose -f devops/compose/docker-compose.stella-ops.yml up -d --force-recreate sbomserviceAddStartupMigrationsauto-applies003_sbom_documents_sidecar.sql,004_ledger_versions_tenant_id.sql, and005_ledger_versions_tenant_chain.sqlagainst the existingsbomschema — forward-only and idempotent, so it is safe on the populated production DB (no back-fill; pre-existing ledger rows gettenant_id = NULLand simply 404 per tenant until re-uploaded). No manualpsqlis required.docker compose up -d <svc>may report “Starting” (not “Recreated”) and keep the old image — always pass--force-recreateto guarantee the swap. - Verify post-recreate with the runbook above (upload under a tenant, GET the subject back byte-for-byte, confirm the cross-tenant 404).
- Enable the ExportCenter side (separate service, separate go-signal): set
ExportCenter:SbomAudit:BaseUrland configure the existingExport:Nis2Soa:ServiceAccountcredential used for tenant-scopedsbom:readtokens. SetExportCenter:VulnAudit:BaseUrlto the SbomService base URL after verifying Endpoint B proxy behavior; the ExportCenter source includes vuln reports on 200 and omits on 404/null.
