SbomService — audit-bundle SBOM endpoint

Status: implemented. Verified against commit 942a18727c (2026-09-26). History: STORAGE + ENDPOINT-A shipped in docs-archive/implplan/SPRINT_20260610_003_SbomService_audit_bundle_sbom_endpoint.md; ENDPOINT-B (SBOM-VULN-PROXY-001) in docs-archive/implplan/SPRINT_20260612_001_SbomService_vuln_audit_proxy_enablement.md. Original consumer contract: docs-archive/implplan/HANDOFF_SbomService_audit_bundle_endpoints.md. The audit-bundle consumer formerly hosted by ExportCenter now runs in the OfflineKit host (offlinekit-web, library src/OfflineKit/__Libraries/StellaOps.ExportCenter.Application/AuditBundle/). Its configuration keys keep the ExportCenter: prefix.

Why

Audit bundles (POST /api/offlinekit/v1/audit-bundles on offlinekit-web, 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. The bundle job fetches real data through ISbomAuditSource/IVulnAuditSource (HttpSbomAuditSource, HttpVulnAuditSource), which return null when SbomService has no data, so the bundle omits the section and never fakes it.

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(SbomDocumentAccess) fetches the verbatim document. SbomDocumentAccess can be constructed only from a ledger version that carries a tenant, so a bare digest cannot reach the sidecar. 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 audit-bundle-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.

The enablement record is docs-archive/implplan/SPRINT_20260612_001_SbomService_vuln_audit_proxy_enablement.md (SBOM-VULN-PROXY-001 proxy proof; EXPORT-VULN-AUDIT-002 bundle inclusion/omission proof).

Storage model — sbom.sbom_documents sidecar

The raw uploaded document was originally ephemeral (parsed in memory for the digest, 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. ISbomDocumentRepository provides UpsertDocumentAsync, GetDocumentByDigestAsync and the payload-free GetMetadataByDigestAsync. Both reads require the same tenant-derived SbomDocumentAccess grant. The Postgres and in-memory implementations are wired alongside the ledger repository in the durable and LocalHarness branches.

Digest vs. verbatim bytes (implementation note). SbomUploadDigest.Compute streams the existing compact JSON serialization into SHA-256; its identity is unchanged from JsonSerializer.SerializeToUtf8Bytes(..., WriteIndented:false). The sidecar instead retains JsonMarshal.GetRawUtf8Value(root). Uploads whose compact JSON bytes are identical share one digest and sidecar row even when their whitespace differs; the first writer’s raw root wins. “Byte-for-byte” refers to that parsed JSON root, excluding the transport wrapper, base64 representation and whitespace outside the root. UTF-8 binary parameters and convert_from/convert_to keep the existing TEXT column and its exact stored bytes. byte_length counts those bytes.

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:

The repository-level defensive DDL that once mirrored these migrations is retired (SPRINT_20260823_003 SBT-6); the embedded migrations are the only schema authority. The end-to-end tests boot the host on a fresh PostgreSQL container and the migration runner applies the full chain 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; the bundle omits it).
    • GET /api/v1/scan/results?subject=... with Scanner unavailable or rejected by outbound allowlists ⇒ 503 problem JSON (truthful outage; never placeholder report JSON).

Audit-bundle consumer configuration (OfflineKit host)

The release bundle (devops/release/bundle/docker-compose.yml, service offlinekit-web) already sets ExportCenter__SbomAudit__BaseUrl and ExportCenter__VulnAudit__BaseUrl to http://sbomservice.stella-ops.local. The equivalent settings:

  1. SBOM source:
    { "ExportCenter": { "SbomAudit": { "BaseUrl": "http://sbomservice.stella-ops.local", "TimeoutSeconds": 30 } } }
    
  2. 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 OfflineKit host, not SbomService. The token provider uses the Export:Nis2Soa:ServiceAccount credential and Export:Nis2Soa:AuthorityBaseUrl.)

Deployment

SbomService ships as service sbomservice in the release bundle. On startup AddStartupMigrations applies every pending embedded migration, including 003, 004 and 005, forward-only and idempotent; no manual psql is required. Pre-existing ledger rows keep tenant_id = NULL and return 404 to every tenant until re-uploaded.

To roll out a rebuilt image to a running estate, recreate the container with --force-recreate (a plain docker compose up -d <svc> can keep the old image), then run the round-trip runbook above: upload under a tenant, fetch the subject back byte-for-byte, and confirm the cross-tenant 404. Rolling out is an operator action that needs explicit authorization.