feat(maven): add the authorization DenialRenderer (S09 Enforcement plan: 8/20)
What does this MR do?
Adds maven.DenialRenderer, the Maven format's implementation of the authz.DenialRenderer seam: a masked 404 rendered through the dispatcher's own method-aware not-found — the standard S01 JSON envelope on GET/HEAD (Maven Resolver 1.9.24+'s RFC9457Reporter escalates an application/problem+json 404 into a fatal transfer error) and the RFC 9457 problem envelope otherwise — a generic 403 naming no permission, and a fail-closed 503 carrying Retry-After and Cache-Control: no-store.
The renderer is not wired yet. The wrap that calls it arrives with the Maven wiring step, so nothing reaches this code at request time on main after this merges. That is why the step carries no dependency.
The masked 404 reuses the dispatcher's own writer rather than rebuilding envelopes: the method-aware branch of the dispatcher's writeNotFound is extracted as the package-level writeNotFoundForMethod, and both the dispatcher and the renderer call it, so byte-identity is structural instead of coincidental. The recorder stamp stays in the dispatcher method — it is server-side telemetry for the remote read arms, which the denial path never wraps. The repository name the seam supplies is deliberately ignored: Maven's genuine not-found is static at every tier, so empty and non-empty names must yield identical bytes, and a test pins exactly that.
Two additions join the package's error taxonomy: CodeServiceUnavailable (the authorization layer's fail-closed 503, distinct from CodeUpstreamUnavailable, which attributes a failure to a remote repository's upstream) and the detailAccessDenied / detailServiceUnavailable strings, which live in denial.go beside their only emitter. CodeForbidden is now shared by the suspended-namespace write gate and the authorization denial, carrying a different detail for each.
Decisions worth a reviewer's attention
Retry-Afteris 5 seconds, mirroring the shipped container/OCI renderer and the management sibling. The constant's comment states the relation a reader can check — 5s equalsdefaultIAMTimeout(internal/config/iam.go) and exceedsdefaultGLAZTimeout(internal/config/glaz.go) — and deliberately does not repeat the container/OCI precedent's "set above the longest of their default timeouts" claim, which is false at 5s against 5s. The spec sentence asking the hint to exceed the longest fail-closed dependency timeout cannot be satisfied as written; !1949 (merged) carries the full argument for the management surface, and rewording the sentence is the spec author's call, so it is flagged here rather than edited from a step MR.- Two differently-shaped 503s can appear on the same Maven route. The authorization 503 encodes its envelope on every method, so a HEAD response carries
Content-Length(net/http drops the body on the wire but keeps the headers GET-faithful); the genuine remote 503,writeProblemForMethodwithhead=trueinremote_artifact.go, suppresses the body and sets noContent-Length. The divergence is deliberate rather than a defect: this MR's variant is the RFC 9110-conformant one, a 503 reveals nothing that needs masking, and reaching the remote 503 requires read access already granted. Cache-Control: no-storeon the 503 is a superset of what the Error Cases table requires for this surface (it names the header only for container/OCI). It is kept as the same defense-in-depth both shipped renderers apply, and pinned by a test so it cannot be dropped silently.- The 403 and 503 write the envelope on every method, HEAD included — net/http drops a HEAD body while keeping the headers GET-faithful — rather than suppressing the body like the dispatcher's
writeProblemForMethod. This matches both shipped renderers; only the masked 404 carries a shape obligation, and its HEAD behavior is identical to the dispatcher's by construction. - S10 (maven-hosted)'s error-code catalog needs an amendment, flagged rather than edited. Its table gives
forbiddenthe detail "Authenticated identity lacks the required grant for this repository." for authorization denials — a string that names the withheld grant and confirms the repository exists, which ADR-021's masking forbids — and carries noservice_unavailablerow. This MR ships the ADR-021-conformant "Access denied." and the new code; the catalog correction is the spec author's call. - Files beyond the plan step's
Files:entry. The step lists onlyinternal/format/maven/denial.goand its test; the extraction inhandler.goand the code constant inproblem.goare the reuse the step's own Scope text prescribes ("reuses the dispatcher's method-aware not-found"), and nothing from a later step is included.
Tests
Mirrors the container/OCI renderer suite with no dropped assertions, and adds beyond it:
- The masked 404 is compared against a genuine response captured by driving the real dispatcher — status, the committed header map, and the body — across GET, HEAD, and PUT, with a pinned correlation id so
request_idcompares non-trivially, a per-methodContent-Typepin so the method-aware split is asserted independently of the shared code path, and an assertion that the parser-recovered name is never echoed. - Empty and non-empty repository names are asserted byte-identical in their own test, pinning the deliberately-ignored parameter. It runs once:
WriteNotFoundignores the name unconditionally, so the property cannot vary by method. - DELETE is pinned to the problem-envelope default on the rendered side. Every method outside GET/HEAD/PUT is
RouteUnmappedand 403s, so this is a defensive arm rather than a reachable one. - The 403 is compared for shape parity against the genuine suspended-namespace 403: status, headers, and envelope code. The denial detail is pinned by value ("Access denied."), and the suspension side only has to be recognizable as the suspension 403, so it is matched on a substring rather than on its exact user-facing wording.
- The 503 pins
Retry-After: 5exactly,Cache-Control: no-store, the wire literalservice_unavailable, and a static detail naming no dependency, permission, or policy — over GET and HEAD, both of which reachWriteUnavailablein production. var _ authz.DenialRendererpins the structural implementation at compile time; there is no production wiring to catch signature drift until the Maven wiring step lands.
The composition-level test proving the wiring passes this renderer rather than another belongs to the Maven wiring step, which owns the wiring.
Diff size
272 lines added, 8 removed, across 4 Go files: 52 in internal/format/maven/denial.go, 204 in internal/format/maven/denial_test.go, and small supporting edits in handler.go and problem.go. Under the reviewable-LOC threshold, so no split justification applies.
e2e scenarios
The Maven catalog's Access control rows stay blocked: the plan moves them off blocked in the Maven wiring step, which is the step that makes denial behavior reachable. This MR changes no request behavior.
Related to #850 (closed)