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 through docs/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

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:

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:

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:

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:

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.ComputeDigestJsonSerializer.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:

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

  1. 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", ... }}'
    
  2. 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
    
  3. Negative checks:
    • Unknown ref ⇒ 404.
    • Same ref with a valid tenant B token ⇒ 404 (tenant isolation).
    • GET /api/v1/scan/results?subject=unknown with Scanner reachable ⇒ 404 (no report; ExportCenter omits).
    • GET /api/v1/scan/results?subject=... with Scanner unavailable or rejected by outbound allowlists ⇒ 503 problem JSON (truthful outage; never placeholder report JSON).

ExportCenter coordination

Once Endpoint A is live:

  1. Set ExportCenter config:
    { "ExportCenter": { "SbomAudit": { "BaseUrl": "http://sbomservice.stella-ops.local", "TimeoutSeconds": 30 } } }
    
  2. ExportCenter’s HttpSbomAuditSource acquires a tenant-scoped client-credentials token with sbom:read, caches it by tenant and scope, and calls GET {BaseUrl}/api/v1/sbom/subject/{urlencoded subjectRef}/cyclonedx with Authorization: Bearer .... Token failure omits the section without sending a raw tenant header.
  3. Configure ExportCenter:VulnAudit:BaseUrl to the same SbomService base URL when vulnerability report sections should be included. HttpVulnAuditSource reuses the tenant-scoped sbom:read bearer-token provider, calls GET {BaseUrl}/api/v1/scan/results?subject={urlencoded subjectRef} without raw tenant headers, includes reports/vuln-report.json on 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:

  1. Build the SbomService image from the committed source (the normal CI/devops image build for stellaops-sbomservice; e.g. the service’s devops/ build path). Pin the resulting digest.
  2. Recreate the container so the new image (and therefore the new endpoints + embedded migrations) is live:
    docker compose -f devops/compose/docker-compose.stella-ops.yml up -d --force-recreate sbomservice
    
    On startup AddStartupMigrations auto-applies 003_sbom_documents_sidecar.sql, 004_ledger_versions_tenant_id.sql, and 005_ledger_versions_tenant_chain.sql against the existing sbom schema — forward-only and idempotent, so it is safe on the populated production DB (no back-fill; pre-existing ledger rows get tenant_id = NULL and simply 404 per tenant until re-uploaded). No manual psql is required.

    docker compose up -d <svc> may report “Starting” (not “Recreated”) and keep the old image — always pass --force-recreate to guarantee the swap.

  3. Verify post-recreate with the runbook above (upload under a tenant, GET the subject back byte-for-byte, confirm the cross-tenant 404).
  4. Enable the ExportCenter side (separate service, separate go-signal): set ExportCenter:SbomAudit:BaseUrl and configure the existing Export:Nis2Soa:ServiceAccount credential used for tenant-scoped sbom:read tokens. Set ExportCenter:VulnAudit:BaseUrl to the SbomService base URL after verifying Endpoint B proxy behavior; the ExportCenter source includes vuln reports on 200 and omits on 404/null.