diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index b5cb43d..7e902cf 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/docs/sbom-nexus-client-contract_v1.md b/docs/sbom-nexus-client-contract_v1.md index c48ee8c..404f236 100644 --- a/docs/sbom-nexus-client-contract_v1.md +++ b/docs/sbom-nexus-client-contract_v1.md @@ -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.1–300 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, diff --git a/src/repo_manager/sbom_client.py b/src/repo_manager/sbom_client.py index b04f346..ba52c7d 100644 --- a/src/repo_manager/sbom_client.py +++ b/src/repo_manager/sbom_client.py @@ -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: diff --git a/tests/test_sbom.py b/tests/test_sbom.py index 75154bc..8de7e3c 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -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) diff --git a/workplans/RMGR-WP-0011-sbom-nexus-production-client.md b/workplans/RMGR-WP-0011-sbom-nexus-production-client.md index a96753e..2225ea6 100644 --- a/workplans/RMGR-WP-0011-sbom-nexus-production-client.md +++ b/workplans/RMGR-WP-0011-sbom-nexus-production-client.md @@ -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.1–300 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