feat(sbom): add authoritative Nexus client

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
tegwick 2026-08-22 23:26:45 +02:00
parent 135b1647d7
commit b068e9da42
5 changed files with 446 additions and 4 deletions

View file

@ -89,9 +89,9 @@
| task | RMGR-WP-0010-T06 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
| task | RMGR-WP-0010-T07 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
| task | RMGR-WP-0011-T01 | done | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
| task | RMGR-WP-0011-T02 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
| task | RMGR-WP-0011-T02 | done | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
| task | RMGR-WP-0011-T03 | done | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
| task | RMGR-WP-0011-T04 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
| task | RMGR-WP-0011-T04 | progress | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
| intake | RMGR-IN-0001 | open | — | intakes/intakes.md |
| intake | RMGR-IN-0002 | open | — | intakes/intakes.md |
| decision | RMGR-DEC-2026-001 | resolved | — | decisions/RMGR-DEC-2026-001-whynot-identifier-batch.md |

View file

@ -58,6 +58,44 @@ Calls use a bounded timeout and surface deterministic transport, HTTP, and
contract errors. Credentials enter only through the platform runtime path and
must never appear in files, command output, or logs.
## Authoritative client usage
The Python client requires `SBOM_NEXUS_URL`. `SBOM_NEXUS_TIMEOUT_SECONDS`
defaults to 30 seconds and is bounded to 0.1300 seconds. An optional
`SBOM_NEXUS_TOKEN` may be injected at runtime; its value is excluded from
configuration representations and all structured errors.
```python
from repo_manager.sbom_client import SBOMNexusClient, SBOMNexusConfig
client = SBOMNexusClient(SBOMNexusConfig.from_environment())
client.upsert_repository(
"example",
nexus_checkout_path="/srv/controlled/example/abc123",
active=True,
)
receipt = client.ingest_repository(
"example",
expected_source_revision="abc123",
operation_id="stable-operation-id",
)
latest = client.latest_snapshot("example")
report = client.licence_report()
```
The client URL-escapes repository slugs, accepts only object responses with the
pinned route-specific fields, and adds an `authoritative-service` context. It
does not retry mutations automatically: a timeout or server failure can occur
after Nexus commits. Structured errors therefore expose
`mutation_may_have_committed`; callers must resolve the stable operation id or
read the resulting snapshot before deciding whether to retry.
When an expected source revision is supplied, an `ingested` receipt for any
other revision fails closed as `source_revision_mismatch` and explicitly notes
that the mutation may already have committed. The checkout path sent to Nexus
is Nexus-local controlled-source identity, never authorization to mount a
workstation path into the cluster.
## Repository source identity and provenance
Repo Manager remains authoritative for repository slug, active state,

View file

@ -6,8 +6,12 @@ import json
import os
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import quote
import httpx
SNAPSHOT_SCHEMA = "sbom-nexus.snapshot.v1"
SNAPSHOT_REQUIRED_FIELDS = frozenset(
@ -32,12 +36,234 @@ PREVIEW_CONTEXT = {
"advances_last_success_at": False,
"creates_snapshot_history": False,
}
SERVICE_CONTEXT = {
"mode": "authoritative-service",
"authoritative": True,
"product_owner": "sbom-nexus",
}
class SBOMContractError(ValueError):
"""Raised when a Nexus response is outside the pinned consumer contract."""
class SBOMServiceError(RuntimeError):
"""A sanitized, deterministic failure from the authoritative service client."""
def __init__(
self,
code: str,
method: str,
path: str,
*,
status_code: int | None = None,
mutation_may_have_committed: bool = False,
) -> None:
self.code = code
self.method = method
self.path = path
self.status_code = status_code
self.mutation_may_have_committed = mutation_may_have_committed
status = f" HTTP {status_code}" if status_code is not None else ""
super().__init__(f"SBOM Nexus {method} {path} failed ({code}{status})")
def to_dict(self) -> dict[str, Any]:
return {
"code": self.code,
"method": self.method,
"path": self.path,
"status_code": self.status_code,
"mutation_may_have_committed": self.mutation_may_have_committed,
}
@dataclass(frozen=True)
class SBOMNexusConfig:
"""Explicit service configuration; credentials are deliberately repr-hidden."""
base_url: str
timeout_seconds: float = 30.0
bearer_token: str | None = field(default=None, repr=False)
def __post_init__(self) -> None:
url = httpx.URL(self.base_url)
if url.scheme not in {"http", "https"} or not url.host:
raise ValueError("SBOM Nexus base_url must be an absolute HTTP(S) URL")
if url.username or url.password or url.query or url.fragment:
raise ValueError("SBOM Nexus base_url must not contain credentials, query, or fragment")
if not 0.1 <= self.timeout_seconds <= 300.0:
raise ValueError("SBOM Nexus timeout_seconds must be between 0.1 and 300")
object.__setattr__(self, "base_url", self.base_url.rstrip("/"))
@classmethod
def from_environment(cls) -> SBOMNexusConfig:
base_url = os.getenv("SBOM_NEXUS_URL")
if not base_url:
raise ValueError("SBOM_NEXUS_URL is required for authoritative service mode")
try:
timeout = float(os.getenv("SBOM_NEXUS_TIMEOUT_SECONDS", "30"))
except ValueError as exc:
raise ValueError("SBOM_NEXUS_TIMEOUT_SECONDS must be numeric") from exc
return cls(
base_url=base_url,
timeout_seconds=timeout,
bearer_token=os.getenv("SBOM_NEXUS_TOKEN") or None,
)
class SBOMNexusClient:
"""Bounded client for Nexus-owned authoritative repository SBOM state."""
def __init__(
self,
config: SBOMNexusConfig,
*,
transport: httpx.BaseTransport | None = None,
) -> None:
self.config = config
self._transport = transport
def upsert_repository(
self,
repo_slug: str,
*,
nexus_checkout_path: str | None,
active: bool = True,
) -> dict[str, Any]:
"""Project identity/path; the path must resolve inside Nexus's controlled source plane."""
return self._request_object(
"PUT",
f"/repositories/{quote(repo_slug, safe='')}",
body={"checkout_path": nexus_checkout_path, "active": active},
operation="repository-projection",
writes_state=True,
required_fields=frozenset({"slug", "active"}),
)
def ingest_repository(
self,
repo_slug: str,
*,
expected_source_revision: str | None = None,
operation_id: str | None = None,
) -> dict[str, Any]:
"""Persist one Nexus scan outcome without automatic mutation retries."""
headers = {"Idempotency-Key": operation_id} if operation_id else None
result = self._request_object(
"POST",
f"/sbom/{quote(repo_slug, safe='')}/ingest",
headers=headers,
operation="ingest",
writes_state=True,
required_fields=frozenset(
{"repo_slug", "snapshot_id", "status", "entry_count", "snapshot_at"}
),
)
if expected_source_revision and result.get("status") == "ingested":
actual = result.get("source_revision")
if actual != expected_source_revision:
raise SBOMServiceError(
"source_revision_mismatch",
"POST",
f"/sbom/{quote(repo_slug, safe='')}/ingest",
mutation_may_have_committed=True,
)
return result
def latest_snapshot(self, repo_slug: str) -> dict[str, Any]:
return self._request_object(
"GET",
f"/sbom/{quote(repo_slug, safe='')}",
operation="latest-snapshot",
writes_state=False,
required_fields=frozenset(
{
"repo_slug",
"last_attempt_at",
"last_success_at",
"last_status",
"entry_count",
"snapshot_id",
"entries",
}
),
)
def licence_report(self) -> dict[str, Any]:
return self._request_object(
"GET",
"/sbom/report/licences/",
operation="licence-report",
writes_state=False,
required_fields=frozenset({"groups", "copyleft_direct_prod", "copyleft_direct_count"}),
)
def _request_object(
self,
method: str,
path: str,
*,
body: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
operation: str,
writes_state: bool,
required_fields: frozenset[str],
) -> dict[str, Any]:
request_headers = dict(headers or {})
if self.config.bearer_token:
request_headers["Authorization"] = f"Bearer {self.config.bearer_token}"
try:
with httpx.Client(
base_url=self.config.base_url,
timeout=self.config.timeout_seconds,
headers=request_headers,
transport=self._transport,
) as client:
response = client.request(method, path, json=body)
except httpx.TimeoutException as exc:
raise SBOMServiceError("timeout", method, path) from exc
except httpx.RequestError as exc:
raise SBOMServiceError("transport_error", method, path) from exc
if response.status_code >= 400:
raise SBOMServiceError(
"http_error",
method,
path,
status_code=response.status_code,
mutation_may_have_committed=writes_state and response.status_code >= 500,
)
try:
payload = response.json()
except ValueError as exc:
raise SBOMServiceError(
"invalid_json",
method,
path,
mutation_may_have_committed=writes_state,
) from exc
if not isinstance(payload, dict):
raise SBOMServiceError(
"invalid_response_type",
method,
path,
mutation_may_have_committed=writes_state,
)
if required_fields - payload.keys():
raise SBOMServiceError(
"contract_error",
method,
path,
mutation_may_have_committed=writes_state,
)
payload["repo_manager_context"] = {
**SERVICE_CONTEXT,
"operation": operation,
"writes_state": writes_state,
}
return payload
def validate_snapshot_contract(payload: dict[str, Any]) -> None:
"""Accept additive fields but reject unknown schemas or missing required fields."""
if payload.get("schema") != SNAPSHOT_SCHEMA:

View file

@ -4,11 +4,15 @@ import json
import subprocess
from pathlib import Path
import httpx
import pytest
from repo_manager.cli import main
from repo_manager.sbom_client import (
SBOMContractError,
SBOMNexusClient,
SBOMNexusConfig,
SBOMServiceError,
licence_report_from_snapshot,
scan_repository_via_nexus,
validate_snapshot_contract,
@ -174,3 +178,157 @@ def test_cli_scan_preserves_output_file_behavior(monkeypatch, tmp_path: Path, ca
assert exit_code == 0
assert json.loads(output.read_text())["schema"] == "sbom-nexus.snapshot.v1"
assert json.loads(capsys.readouterr().out)["product_owner"] == "sbom-nexus"
def test_service_config_is_explicit_bounded_and_redacts_token(monkeypatch) -> None:
monkeypatch.setenv("SBOM_NEXUS_URL", "https://nexus.example.test/")
monkeypatch.setenv("SBOM_NEXUS_TIMEOUT_SECONDS", "12.5")
monkeypatch.setenv("SBOM_NEXUS_TOKEN", "do-not-print-this")
config = SBOMNexusConfig.from_environment()
assert config.base_url == "https://nexus.example.test"
assert config.timeout_seconds == 12.5
assert "do-not-print-this" not in repr(config)
with pytest.raises(ValueError, match="must not contain credentials"):
SBOMNexusConfig("https://user:secret@nexus.example.test")
with pytest.raises(ValueError, match="between 0.1 and 300"):
SBOMNexusConfig("https://nexus.example.test", timeout_seconds=301)
def test_authoritative_client_calls_pinned_routes_and_marks_service_context() -> None:
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
if request.url.path.startswith("/repositories/"):
return httpx.Response(200, json={"slug": "demo", "active": True})
if request.method == "POST":
return httpx.Response(
200,
json={
"repo_slug": "demo",
"snapshot_id": "snapshot-1",
"status": "ingested",
"entry_count": 2,
"snapshot_at": "2026-08-22T20:00:00Z",
"source_revision": "abc123",
},
)
if request.url.path == "/sbom/report/licences/":
return httpx.Response(
200,
json={
"groups": [],
"copyleft_direct_prod": [],
"copyleft_direct_count": 0,
},
)
return httpx.Response(
200,
json={
"repo_slug": "demo",
"last_attempt_at": "2026-08-22T20:00:00Z",
"last_success_at": "2026-08-22T20:00:00Z",
"last_status": "ingested",
"entry_count": 2,
"snapshot_id": "snapshot-1",
"entries": [],
},
)
client = SBOMNexusClient(
SBOMNexusConfig("https://nexus.example.test", bearer_token="runtime-secret"),
transport=httpx.MockTransport(handler),
)
projected = client.upsert_repository(
"demo",
nexus_checkout_path="/srv/controlled/demo/abc123",
)
ingested = client.ingest_repository(
"demo",
expected_source_revision="abc123",
operation_id="operation-1",
)
latest = client.latest_snapshot("demo")
report = client.licence_report()
assert [request.url.path for request in calls] == [
"/repositories/demo",
"/sbom/demo/ingest",
"/sbom/demo",
"/sbom/report/licences/",
]
assert all(request.headers["Authorization"] == "Bearer runtime-secret" for request in calls)
assert calls[1].headers["Idempotency-Key"] == "operation-1"
assert projected["repo_manager_context"]["writes_state"] is True
assert ingested["repo_manager_context"]["mode"] == "authoritative-service"
assert latest["repo_manager_context"]["authoritative"] is True
assert report["repo_manager_context"]["operation"] == "licence-report"
def test_authoritative_client_fails_closed_on_source_revision_mismatch() -> None:
transport = httpx.MockTransport(
lambda request: httpx.Response(
200,
json={
"repo_slug": "demo",
"snapshot_id": "snapshot-1",
"status": "ingested",
"entry_count": 1,
"snapshot_at": "2026-08-22T20:00:00Z",
"source_revision": "different",
},
)
)
client = SBOMNexusClient(SBOMNexusConfig("https://nexus.example.test"), transport=transport)
with pytest.raises(SBOMServiceError) as raised:
client.ingest_repository("demo", expected_source_revision="expected")
assert raised.value.code == "source_revision_mismatch"
assert raised.value.mutation_may_have_committed is True
def test_authoritative_client_has_sanitized_timeout_and_http_failures() -> None:
secret = "never-echo-this-token"
def timeout_handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("upstream timed out", request=request)
timeout_client = SBOMNexusClient(
SBOMNexusConfig("https://nexus.example.test", bearer_token=secret),
transport=httpx.MockTransport(timeout_handler),
)
with pytest.raises(SBOMServiceError) as timeout:
timeout_client.latest_snapshot("demo")
assert timeout.value.to_dict()["code"] == "timeout"
assert secret not in str(timeout.value)
assert secret not in json.dumps(timeout.value.to_dict())
http_client = SBOMNexusClient(
SBOMNexusConfig("https://nexus.example.test", bearer_token=secret),
transport=httpx.MockTransport(lambda request: httpx.Response(503, json={"detail": secret})),
)
with pytest.raises(SBOMServiceError) as failed:
http_client.ingest_repository("demo")
assert failed.value.status_code == 503
assert failed.value.mutation_may_have_committed is True
assert secret not in str(failed.value)
assert secret not in json.dumps(failed.value.to_dict())
def test_authoritative_client_rejects_malformed_success_without_exposing_body() -> None:
client = SBOMNexusClient(
SBOMNexusConfig("https://nexus.example.test"),
transport=httpx.MockTransport(
lambda request: httpx.Response(200, json={"unexpected": "sensitive body"})
),
)
with pytest.raises(SBOMServiceError) as failed:
client.latest_snapshot("demo")
assert failed.value.code == "contract_error"
assert "sensitive body" not in str(failed.value)

View file

@ -60,7 +60,7 @@ workstation filesystem must never be mounted into the cluster.
```task
id: RMGR-WP-0011-T02
status: todo
status: done
priority: high
state_hub_task_id: "280cfa84-1561-5cb9-8943-aa0775c57be6"
```
@ -70,6 +70,19 @@ projection, ingest, latest-snapshot, and licence-report surfaces needed by Repo
Manager. Preserve bounded timeouts and actionable failures; credentials, when
introduced through the platform path, must never enter files, output, or logs.
**Result (2026-08-22):** `SBOMNexusClient` provides the four pinned service
operations with explicit `SBOM_NEXUS_URL`, a configurable 0.1300 second bound,
route-specific success validation, URL-safe slugs, and sanitized deterministic
timeout/transport/HTTP/JSON/contract errors. Optional runtime bearer
credentials are representation-hidden and never copied into failures.
Mutation calls are not retried implicitly. Ingest accepts a stable
`Idempotency-Key`, and failures state whether the operation may already have
committed. Callers may require an exact source revision; a mismatched ingested
receipt fails closed while preserving that commit-uncertainty signal. The
repository projection parameter is explicitly Nexus-local
`nexus_checkout_path`, subject to the controlled-source boundary in T01.
## Make local scanning an explicit preview
```task
@ -96,7 +109,7 @@ contract tests carry the same semantics.
```task
id: RMGR-WP-0011-T04
status: todo
status: progress
priority: medium
state_hub_task_id: "81fed060-3431-5d9b-819b-fcc7d629c364"
```
@ -106,6 +119,13 @@ prove by source inspection that Repo Manager has no scanner implementation,
snapshot store, freshness evaluation, catch-up policy, or licence classifier.
Capture the exact production handoff evidence required by SBOM-WP-0002.
**Progress (2026-08-22):** contract tests now cover additive preview schemas,
unknown schema rejection, all four authoritative routes, bounded configuration,
credential redaction, malformed success responses, timeouts, server failures,
commit uncertainty, idempotency headers, and revision mismatch. Remaining is
the production consumer handoff and final source/ownership inspection after the
controlled source-input topology is available.
## Acceptance
- Authoritative mode talks to SBOM Nexus and returns its pinned snapshot