feat(managementapi): send cache-private headers on management reads (S09 Verdicts plan: 9/10)

What changes for a caller

Every response from the management API now says which credential it belongs to, and an error says it must not be kept at all:

HTTP/1.1 200 OK
Cache-Control: private, max-age=0
Vary: Authorization
X-Request-ID: 01K4...
HTTP/1.1 404 Not Found
Cache-Control: no-store
Vary: Authorization
X-Request-ID: 01K4...

One wrap inside NewHandler sets Vary and the private value before any route runs, then swaps in no-store when the committed status is 400 or above. It is the only thing that reaches the 404 and 405 the mux writes itself, which no envelope writer ever sees. Writers that send no-store on their own — the fail-closed denial, transport.WriteError — land on the same value, so the order the two stamps arrive in cannot matter.

The authentication layer's 401 is written before routing, outside this handler, and keeps its own header set. components/responses/Unauthorized is therefore the one error response deliberately left without either header.

Why safe

Management reads answer per principal: visibility-filtered, role-gated, or verdict-carrying. A shared cache that stored one and served it to a different credential would leak another organization's data, and a cached cross-organization 404 would hide a real namespace from its own members. no-store forbids that storage outright instead of relying on a private cache to key on the credential correctly.

No route behaviour changes. No new dependency, no Deps field, no route-table edit. must-revalidate in place of the private value would be worse, since RFC 9111 §3.5 names it as one of the directives that make an Authorization-bearing response storable by a shared cache.

Each of the three header components is declared required with a single-value enum, so the response validations the handler suites already run assert the headers rather than just documenting them. The 503 responses join them here: they pointed at a loose Cache-Control declaration carrying neither, which asserted nothing.

The wrapping writer implements Unwrap, so http.ResponseController still reaches the connection underneath and the destructive delete's write deadline still arms.

Which OpenAPI blocks got the $refs

The shared error responses, and the 200 of 22 read operations. Three read 200s are deliberately untouched — the repository list, the repository detail, and the namespace details endpoint — because sibling merge requests own those operation blocks and a second writer there is a conflict. They carry the headers at runtime today; their $refs arrive with their owners. Write success responses are not declared either: the wrap is method-blind so they carry the pair, but the obligation is stated over reads, and an undeclared response header is permitted.

components/headers/Cache-Control is gone. It described a rule that no longer holds — the private value on every status but an authorization-time 503 — and its two successors state one value each: CacheControlPrivate on a success, CacheControlNoStore on an error.

Spec coverage

Spec: S09 Authorization, the Caching section and its acceptance criterion on cache-private management reads.

Clause Test
The private pair on every success status TestManagementHandler_CachePrivateHeadersOnEveryStatus200 on list, detail and namespace details
no-store on every error status the same sweep's 404, 405 and 400 rows
The 404 and 405 the mux writes itself, which no writer sees TestManagementHandler_MuxWrittenErrorsCarryNoStore
The cross-organization and unknown-slug 404 .../unknown_slug_404
Parameter present or absent .../include_permissions_present, plus the seven rows without it
A denial that already sends no-store agrees with the wrap TestManagementHandler_UnavailableDenialKeepsNoStore
A 503 no denial rendered also sends no-store TestManagementHandler_DependencyUnavailableAlsoNoStore
A 403 through the handler carries both headers TestDenialRenderer_WriteForbiddenMatchesGenuineForbiddenShape
Declared value and presence, across every validated response the existing contract validations, via required + enum
The three header components exist and are strings TestContract_CacheHeadersAreDeclared
The authentication layer's 401 keeps S08's header set out of scope here: written before routing, so no request through NewHandler reaches it

The three denial_test.go tests compare a whole header map between a response served through NewHandler and a bare renderer call, so they replay the pair on the renderer side. Nothing is weakened: the genuine side still comes from production code, so a wrong or missing value fails all three.

Merge order

Merge request Overlap with this one Order
!2295 (merged) repository list envelope api/openapi/v1.yaml, disjoint regions — it owns the listRepositories block, this one never touches it either
Detail verdicts (not yet opened) api/openapi/v1.yaml, the getRepository block it owns either
Namespace verdicts (not yet opened) api/openapi/v1.yaml, the getNamespace block it owns either
!2224 (merged) namespace connection test internal/managementapi/handler.go, export_test.go, api/openapi/v1.yaml — different anchors in each, so whichever lands second may need a small rebase either
!2266 (merged) unconfigured authorization posture none either
!2316 (merged) no-store on error responses no file overlap; it amends S09 so error statuses carry Cache-Control: no-store, and stamps that value inside transport.WriteErrorWithDetails, which this surface reaches !2316 (merged) first

!2316 (merged) had to land before this one, and it has: this branch is rebased on it and the wrap is status-aware, so the two agree. In the other order git would still have merged them cleanly and nothing would have flagged the clash — it would have surfaced in CI, as the error-side assertions in cache_headers_test.go and denial_test.go and every contract validation over an error response.

e2e scenarios

No scenario is added or affected. The catalogs under docs/testing/e2e/ cover format-client flows, which do not reach /api/v1, and cache directives on a management response are not observable in any of them. The management catalog the namespace-endpoint work introduces is not this change's to write.

Related to #670 (closed)

Edited by João Pereira

Merge request reports

Loading
Loading