Skip to content

Commit 6bbb660

Browse files
lukpuehclaude
andauthored
Wrap successful SBOM upload response with polling URL (#47)
On success, replace the raw DependencyTrack response relay with a PIA envelope exposing only `polling_url`. The publisher does not know the DependencyTrack instance URL, but DT's token-polling endpoint does not require PIA authentication, so the publisher can query DT directly once PIA hands them the full URL. Failure responses are still relayed verbatim: DT's own error body (e.g. CycloneDX validation message) is the most useful information on failure, and wrapping it would either drop it, double-encode it, or duplicate it without adding anything actionable. Signed-off-by: Lukas Puehringer <lukas.puehringer@eclipse-foundation.org> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 823b061 commit 6bbb660

4 files changed

Lines changed: 100 additions & 11 deletions

File tree

docs/DESIGN.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,13 @@ Content-Type: application/json
196196
```
197197

198198
**Response:**
199+
- `200 OK`: SBOM accepted by DependencyTrack. Body provides the URL the
200+
publisher should poll for processing status:
201+
```json
202+
{
203+
"polling_url": "https://sbom.eclipse.org/api/v1/bom/token/<token>"
204+
}
205+
```
199206
- `401 Unauthorized`:
200207
- Invalid Authorization header format
201208
- Invalid token
@@ -206,7 +213,7 @@ Content-Type: application/json
206213
- No matching DependencyTrack project found
207214
- `422 Unprocessable Entity`: Missing Authorization header or invalid JSON
208215
- `502`: DependencyTrack upload request failed
209-
- `*`: Relay DependencyTrack status code
216+
- `*`: Relay DependencyTrack status code and body verbatim (non-2xx)
210217

211218

212219
### 4.2 Settings

pia/main.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .models import (
1515
DependencyTrackUploadPayload,
1616
PiaUploadPayload,
17+
PiaUploadResponse,
1718
Workload,
1819
find_dt_project,
1920
find_workload_by_claims,
@@ -185,14 +186,36 @@ async def upload_sbom(
185186
settings.dependency_track_api_key,
186187
dt_payload,
187188
)
188-
return Response(
189-
content=dt_response.content,
190-
status_code=dt_response.status_code,
191-
media_type="application/json",
192-
)
193189
except dependencytrack.DependencyTrackError as e:
194190
logger.error(f"DependencyTrack upload failed: {e}")
195191
raise HTTPException(
196192
status_code=status.HTTP_502_BAD_GATEWAY,
197193
detail="Failed to upload to DependencyTrack",
198194
) from e
195+
196+
# Relay DT failures verbatim; on success, return the polling URL the
197+
# publisher should query for processing status.
198+
if not dt_response.ok:
199+
return Response(
200+
content=dt_response.content,
201+
status_code=dt_response.status_code,
202+
media_type="application/json",
203+
)
204+
205+
try:
206+
token = dt_response.json()["token"]
207+
except (ValueError, KeyError):
208+
# DT returned a 2xx with an unexpected body shape — the upload
209+
# likely landed, but we can't hand the publisher a polling URL.
210+
# Log full context and re-raise so FastAPI returns 500: a retry
211+
# is NOT safe (it would duplicate the SBOM in DT).
212+
logger.error(
213+
f"DependencyTrack returned unparseable success response "
214+
f"(status={dt_response.status_code}, body={dt_response.text!r})"
215+
)
216+
raise
217+
218+
dt_url = str(settings.dependency_track_url).rstrip("/")
219+
return PiaUploadResponse(
220+
polling_url=f"{dt_url}/token/{token}", # type: ignore[arg-type]
221+
)

pia/models.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
from typing import Any
55

6-
from pydantic import BaseModel, ConfigDict, Field
6+
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
77
from sqlalchemy import ForeignKey, Select, String, UniqueConstraint, select
88
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
99

@@ -201,6 +201,15 @@ class PiaUploadPayload(BaseModel):
201201
model_config = ConfigDict(use_attribute_docstrings=True)
202202

203203

204+
class PiaUploadResponse(BaseModel):
205+
"""Response for a successful PIA SBOM upload."""
206+
207+
polling_url: HttpUrl
208+
"""DependencyTrack URL to poll for processing status of this upload."""
209+
210+
model_config = ConfigDict(use_attribute_docstrings=True)
211+
212+
204213
class DependencyTrackUploadPayload(BaseModel):
205214
"""Payload for DependencyTrack SBOM upload."""
206215

tests/test_main.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,17 +153,67 @@ def test_upload_success(
153153
valid_request_data,
154154
authenticate_as_workload,
155155
):
156-
"""Successful SBOM upload."""
156+
"""Successful SBOM upload returns DT polling URL."""
157157
mock_dt_response = Mock()
158+
mock_dt_response.ok = True
158159
mock_dt_response.status_code = 200
159-
mock_dt_response.content = b"content"
160+
mock_dt_response.json.return_value = {"token": "dt-token-abc"}
160161
mock_upload.return_value = mock_dt_response
161162

162163
response = client.post("/v1/upload/sbom", json=valid_request_data)
163164

164165
assert response.status_code == 200
165-
assert response.content == b"content"
166-
assert response.headers["content-type"] == "application/json"
166+
assert response.json() == {
167+
"polling_url": "https://sbom.eclipse.org/api/v1/bom/token/dt-token-abc"
168+
}
169+
170+
@patch("pia.main.dependencytrack.upload_sbom")
171+
def test_upload_dt_malformed_success_body(
172+
self,
173+
mock_upload,
174+
client,
175+
valid_request_data,
176+
authenticate_as_workload,
177+
caplog,
178+
):
179+
"""A 2xx DT response without a 'token' field propagates an error.
180+
181+
TestClient re-raises server exceptions; in production FastAPI's ASGI
182+
server converts them to 500. Either way the publisher does not get
183+
a misleading 200.
184+
"""
185+
mock_dt_response = Mock()
186+
mock_dt_response.ok = True
187+
mock_dt_response.status_code = 200
188+
mock_dt_response.json.return_value = {"unexpected": "shape"}
189+
mock_dt_response.text = '{"unexpected": "shape"}'
190+
mock_upload.return_value = mock_dt_response
191+
192+
with pytest.raises(KeyError):
193+
client.post("/v1/upload/sbom", json=valid_request_data)
194+
195+
assert "unparseable success response" in caplog.text
196+
assert "unexpected" in caplog.text
197+
198+
@patch("pia.main.dependencytrack.upload_sbom")
199+
def test_upload_dt_non_ok_relayed(
200+
self,
201+
mock_upload,
202+
client,
203+
valid_request_data,
204+
authenticate_as_workload,
205+
):
206+
"""Non-2xx DT responses are relayed verbatim, not wrapped."""
207+
mock_dt_response = Mock()
208+
mock_dt_response.ok = False
209+
mock_dt_response.status_code = 400
210+
mock_dt_response.content = b'{"detail":"invalid bom"}'
211+
mock_upload.return_value = mock_dt_response
212+
213+
response = client.post("/v1/upload/sbom", json=valid_request_data)
214+
215+
assert response.status_code == 400
216+
assert response.content == b'{"detail":"invalid bom"}'
167217

168218
def test_upload_invalid_json(self, client, authenticate_as_workload):
169219
"""Error with invalid JSON."""

0 commit comments

Comments
 (0)