Skip to content

Commit be0d6b0

Browse files
authored
fix(notice): honour the ?scan_id= release pin on NOTICE downloads
Every other detail read accepts the release-snapshot anchor; NOTICE resolved the latest succeeded scan internally, so the NOTICE for an already-shipped release became unreachable once a newer scan succeeded. The download-history row now records the scan actually rendered instead of re-resolving "latest".
1 parent 29d7ed5 commit be0d6b0

12 files changed

Lines changed: 196 additions & 15 deletions

File tree

apps/backend/api/v1/obligations.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
get_project,
7070
)
7171
from services.report_download_service import record_report_download
72-
from services.scan_resolution import SnapshotScanNotFound, latest_succeeded_scan_id
72+
from services.scan_resolution import SnapshotScanNotFound
7373

7474
router = APIRouter(prefix="/v1", tags=["obligations"])
7575
log = structlog.get_logger("obligations.api")
@@ -280,7 +280,7 @@ def _format_content_disposition(project_name: str, ext: str) -> str:
280280

281281
@router.get(
282282
"/projects/{project_id}/notice",
283-
summary="Compose a NOTICE attribution body for the project's latest scan",
283+
summary="Compose a NOTICE attribution body for the project's latest scan (or a pinned one)",
284284
responses={
285285
200: {
286286
"description": (
@@ -297,8 +297,9 @@ def _format_content_disposition(project_name: str, ext: str) -> str:
297297
},
298298
404: {
299299
"description": (
300-
"Project does not exist, or the caller is not a member of the "
301-
"project's team (existence-hidden)."
300+
"Project does not exist, the caller is not a member of the "
301+
"project's team (existence-hidden), or a pinned ``scan_id`` is "
302+
"not a succeeded scan of this project."
302303
)
303304
},
304305
429: {
@@ -311,6 +312,17 @@ def _format_content_disposition(project_name: str, ext: str) -> str:
311312
async def get_project_notice_endpoint(
312313
request: Request,
313314
project_id: uuid.UUID,
315+
scan_id: uuid.UUID | None = Query(
316+
default=None,
317+
description=(
318+
"Optional release-snapshot anchor. When given, compose the NOTICE "
319+
"from this SPECIFIC succeeded scan instead of the project's latest "
320+
"succeeded scan — this is how the attribution document for an "
321+
"already-shipped release stays retrievable after a newer scan "
322+
"succeeds. Must belong to this project and be succeeded, else 404. "
323+
"Omit for the default latest-succeeded behaviour."
324+
),
325+
),
314326
fmt: str = Query(
315327
default="text",
316328
alias="format",
@@ -336,7 +348,10 @@ async def get_project_notice_endpoint(
336348
project_id=project_id,
337349
actor=actor,
338350
fmt=fmt,
351+
snapshot_scan_id=scan_id,
339352
)
353+
except SnapshotScanNotFound:
354+
return _problem_for_snapshot_not_found(request)
340355
except (ObligationError, ProjectError) as exc:
341356
return _problem_for_obligation_error(request, exc)
342357

@@ -370,9 +385,11 @@ async def get_project_notice_endpoint(
370385
# already enforced existence-hide team membership, so reaching this point
371386
# means the actor is allowed to read this project. We load the project for
372387
# its ``team_id`` (denormalised onto every history row so admin / team-wide
373-
# queries do not need a join) and resolve the latest succeeded scan as the
374-
# snapshot anchor, mirroring what the NOTICE service rendered against.
375-
# Best-effort: ANY DB error inside the helper is logged + swallowed.
388+
# queries do not need a join) and record the scan the service actually
389+
# rendered against — re-resolving "latest succeeded" here would mislabel a
390+
# pinned download, and could even disagree with an unpinned one if a scan
391+
# succeeded mid-request. Best-effort: ANY DB error inside the helper is
392+
# logged + swallowed.
376393
body_bytes = (
377394
payload["body"].encode("utf-8")
378395
if isinstance(payload["body"], str)
@@ -386,11 +403,10 @@ async def get_project_notice_endpoint(
386403
# between the two reads). Skip the emit silently — never 5xx.
387404
project = None
388405
if project is not None:
389-
resolved_scan_id = await latest_succeeded_scan_id(session, project_id)
390406
await record_report_download(
391407
session,
392408
project=project,
393-
scan_id=resolved_scan_id,
409+
scan_id=payload["scan_id"],
394410
user=actor,
395411
report_type="notice",
396412
fmt=fmt,

apps/backend/services/obligation_service.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,10 +1239,21 @@ async def generate_notice(
12391239
project_id: uuid.UUID,
12401240
actor: CurrentUser,
12411241
fmt: str = "text",
1242+
snapshot_scan_id: uuid.UUID | None = None,
12421243
) -> dict[str, Any]:
12431244
"""
12441245
Compose a NOTICE attribution body for the project's latest scan.
12451246
1247+
``snapshot_scan_id`` pins the document to a SPECIFIC succeeded snapshot
1248+
instead of the latest succeeded scan — the same anchor every other detail
1249+
read already accepts (obligations / licenses / vulnerabilities / SBOM).
1250+
Without it, "give me the NOTICE we shipped with 4.0" became impossible the
1251+
moment a 4.1 scan succeeded: the attribution document a release earned is
1252+
exactly the artefact that must stay retrievable after the next scan lands.
1253+
Validated by :func:`services.scan_resolution.resolve_snapshot_scan_id`
1254+
(cross-project / non-succeeded / nonexistent → :class:`SnapshotScanNotFound`
1255+
→ 404 at the router, so a pinned id cannot probe another project's scans).
1256+
12461257
Output shape (text format)::
12471258
12481259
Third-party Licenses for <project_name>
@@ -1300,16 +1311,18 @@ async def generate_notice(
13001311

13011312
generated_at = datetime.now(tz=UTC)
13021313

1303-
# Anchor the NOTICE on the latest SUCCEEDED scan — not
1314+
# Anchor on the resolved snapshot scan — the pinned ``snapshot_scan_id``
1315+
# when given, else the latest SUCCEEDED scan; never
13041316
# ``project.latest_scan_id`` (the last *attempted* scan). A failed newest
13051317
# attempt must not erase the attribution document the last good scan earned.
13061318
# See ``services.scan_resolution``.
1307-
scan_id = await latest_succeeded_scan_id(session, project_id)
1319+
scan_id = await resolve_snapshot_scan_id(session, project_id, snapshot_scan_id)
13081320
if scan_id is None:
13091321
body = _render_empty_notice(project.name, generated_at, fmt=fmt)
13101322
return {
13111323
"project_id": project.id,
13121324
"project_name": project.name,
1325+
"scan_id": None,
13131326
"generated_at": generated_at,
13141327
"format": fmt,
13151328
"body": body,
@@ -1345,6 +1358,10 @@ async def generate_notice(
13451358
return {
13461359
"project_id": project.id,
13471360
"project_name": project.name,
1361+
# The scan this body was actually rendered against. The router reuses it
1362+
# for the Reports-center history row so the recorded snapshot is the one
1363+
# the user downloaded, not whatever is latest by the time we log.
1364+
"scan_id": scan_id,
13481365
"generated_at": generated_at,
13491366
"format": fmt,
13501367
"body": body,

apps/backend/tests/integration/test_release_snapshots_api.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,55 @@ async def test_gate_result_anchor_reflects_pinned_snapshot(client) -> None:
487487
assert pinned.json()["critical_cve_count"] == 2
488488

489489

490+
async def test_notice_anchor_pins_older_scan(client) -> None:
491+
# The NOTICE is the artefact a shipped release is judged by, so it has to
492+
# honour the same pin the tab above it does. Without the anchor, "give me
493+
# the NOTICE we shipped with the previous release" stops being answerable
494+
# the moment a newer scan succeeds.
495+
user, project_id, older, _latest = await _seed_two_snapshot_project(client)
496+
headers = _bearer_for(user)
497+
498+
# Default (latest succeeded): no license findings on that scan at all.
499+
default = await client.get(f"/v1/projects/{project_id}/notice", headers=headers)
500+
assert default.status_code == 200, default.text
501+
assert default.headers["x-notice-license-count"] == "0"
502+
assert "GPL-3.0-only" not in default.text
503+
504+
# Pinned to the OLDER scan: its forbidden license must be credited.
505+
pinned = await client.get(
506+
f"/v1/projects/{project_id}/notice",
507+
headers=headers,
508+
params={"scan_id": str(older)},
509+
)
510+
assert pinned.status_code == 200, pinned.text
511+
assert pinned.headers["x-notice-license-count"] == "1"
512+
assert "GPL-3.0-only" in pinned.text
513+
514+
515+
async def test_notice_anchor_non_succeeded_scan_id_is_404(client) -> None:
516+
team, user = await _seed_team_with_user(client)
517+
project_id = await _seed_empty_project(client, team_id=team.id)
518+
headers = _bearer_for(user)
519+
await _seed_succeeded_scan(
520+
client, project_id=project_id, created_at=datetime(2026, 5, 20, tzinfo=UTC), n_high=1
521+
)
522+
failed_scan = await _seed_succeeded_scan(
523+
client,
524+
project_id=project_id,
525+
created_at=datetime(2026, 5, 22, tzinfo=UTC),
526+
status="failed",
527+
)
528+
529+
for pinned_scan_id in (failed_scan, uuid.uuid4()):
530+
response = await client.get(
531+
f"/v1/projects/{project_id}/notice",
532+
headers=headers,
533+
params={"scan_id": str(pinned_scan_id)},
534+
)
535+
assert response.status_code == 404, response.text
536+
assert response.headers["content-type"].startswith(PROBLEM_JSON)
537+
538+
490539
async def test_omitting_scan_id_returns_latest_succeeded(client) -> None:
491540
user, project_id, older, latest = await _seed_two_snapshot_project(client)
492541
headers = _bearer_for(user)
@@ -526,6 +575,7 @@ async def test_anchor_idor_other_project_scan_id_is_404(client) -> None:
526575
f"/v1/projects/{project_id}/obligations",
527576
f"/v1/projects/{project_id}/gate-result",
528577
f"/v1/projects/{project_id}/sbom",
578+
f"/v1/projects/{project_id}/notice",
529579
):
530580
response = await client.get(
531581
path, headers=headers, params={"scan_id": str(foreign_scan)}

apps/backend/tests/unit/openapi_endpoints.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,8 @@
278278
"GET /v1/projects/{project_id}/notice": [
279279
"download",
280280
"format",
281-
"project_id"
281+
"project_id",
282+
"scan_id"
282283
],
283284
"GET /v1/projects/{project_id}/obligations": [
284285
"category",

apps/frontend/src/features/projects/api/obligationsApi.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,13 @@ export interface FetchNoticeParams {
201201
format?: NoticeFormat;
202202
/** When true, ask the backend to set Content-Disposition: attachment. */
203203
download?: boolean;
204+
/**
205+
* Release-snapshot anchor. When the tab is pinned to a past release, the
206+
* NOTICE must be composed from that scan too — otherwise the downloaded
207+
* attribution document describes the latest scan while the table above it
208+
* shows the pinned one.
209+
*/
210+
scanId?: string;
204211
}
205212

206213
export async function fetchProjectNotice(
@@ -212,6 +219,9 @@ export async function fetchProjectNotice(
212219
params: {
213220
format: fmt,
214221
...(params.download ? { download: true } : {}),
222+
...(params.scanId != null && params.scanId.length > 0
223+
? { scan_id: params.scanId }
224+
: {}),
215225
},
216226
responseType: "text",
217227
transformResponse: [(raw: string) => raw],

apps/frontend/src/features/projects/api/useNotice.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ import { safeFilenameToken, triggerBlobDownload } from "@/lib/download";
2222

2323
export interface UseNoticeOptions {
2424
defaultFormat?: NoticeFormat;
25+
/**
26+
* Release-snapshot anchor forwarded to the backend. Pass the tab's pinned
27+
* scan so the downloaded NOTICE describes the release on screen rather than
28+
* whatever scan succeeded most recently.
29+
*/
30+
scanId?: string;
2531
}
2632

2733
export interface UseNoticeReturn {
@@ -69,6 +75,7 @@ export function useNotice(
6975
const result = await fetchProjectNotice(projectId, {
7076
format: fmt,
7177
download: true,
78+
scanId: options.scanId,
7279
});
7380
const ext = fmt === "markdown" ? "md" : fmt === "html" ? "html" : "txt";
7481
const fallbackName = `NOTICE-${safeFilenameToken(projectName ?? projectId)}.${ext}`;
@@ -83,7 +90,7 @@ export function useNotice(
8390
setIsLoading(false);
8491
}
8592
},
86-
[projectId, projectName, options.defaultFormat],
93+
[projectId, projectName, options.defaultFormat, options.scanId],
8794
);
8895

8996
return { download, isLoading, error, lastResult };

apps/frontend/src/features/projects/components/ComplianceTab.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ export function ComplianceTab({
369369
<ComplianceToolbar
370370
projectId={projectId}
371371
projectName={projectName}
372+
scanId={scanId}
372373
search={search}
373374
onSearchChange={setSearch}
374375
categories={categories}
@@ -491,6 +492,8 @@ interface ComplianceToolbarProps {
491492
projectId: string;
492493
/** M-21 — names the downloaded NOTICE file (id fallback when null). */
493494
projectName: string | null;
495+
/** Release-snapshot pin, so the NOTICE matches the grid below it. */
496+
scanId?: string;
494497
search: string;
495498
onSearchChange: (value: string) => void;
496499
categories: LicenseCategoryName[];
@@ -506,6 +509,7 @@ interface ComplianceToolbarProps {
506509
function ComplianceToolbar({
507510
projectId,
508511
projectName,
512+
scanId,
509513
search,
510514
onSearchChange,
511515
categories,
@@ -626,6 +630,7 @@ function ComplianceToolbar({
626630
<ComplianceNoticeDownload
627631
projectId={projectId}
628632
projectName={projectName}
633+
scanId={scanId}
629634
/>
630635
</div>
631636
);
@@ -643,13 +648,16 @@ function ComplianceToolbar({
643648
function ComplianceNoticeDownload({
644649
projectId,
645650
projectName,
651+
scanId,
646652
}: {
647653
projectId: string;
648654
projectName: string | null;
655+
scanId?: string;
649656
}) {
650657
const { t } = useTranslation("project_detail");
651658
const notice = useNotice(projectId, projectName ?? undefined, {
652659
defaultFormat: "text",
660+
scanId,
653661
});
654662
const [format, setFormat] = useState<NoticeFormat>("text");
655663
return (

apps/frontend/src/features/projects/components/ObligationsTab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ export function ObligationsTab({
213213
);
214214

215215
const obligations = useObligations(projectId, filters);
216-
const notice = useNotice(projectId, projectName ?? undefined);
216+
const notice = useNotice(projectId, projectName ?? undefined, { scanId });
217217

218218
const items: ObligationListItem[] = obligations.data?.items ?? [];
219219
const total = obligations.data?.total ?? 0;

apps/frontend/src/features/projects/components/ReportsTab.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,11 @@ export function ReportsTab({
309309
read-only grid and the old ObligationsTab affordance is unrouted,
310310
so deep-linking there was a dead end. Same direct-download pattern
311311
as VulnPdfCard. */}
312-
<NoticeCard projectId={projectId} projectName={projectName} />
312+
<NoticeCard
313+
projectId={projectId}
314+
projectName={projectName}
315+
scanId={scanId}
316+
/>
313317
{/* W4-C #21 — SBOM card scrolls to the in-page SBOM section below
314318
instead of navigating to a separate tab. */}
315319
<GenerateCard
@@ -580,13 +584,16 @@ function VulnPdfCard({ projectId }: { projectId: string }) {
580584
function NoticeCard({
581585
projectId,
582586
projectName,
587+
scanId,
583588
}: {
584589
projectId: string;
585590
projectName?: string | null;
591+
scanId?: string;
586592
}) {
587593
const { t } = useTranslation("project_detail");
588594
const notice = useNotice(projectId, projectName ?? undefined, {
589595
defaultFormat: "text",
596+
scanId,
590597
});
591598
const [format, setFormat] = useState<NoticeFormat>("text");
592599
return (

0 commit comments

Comments
 (0)