feat(npm): add the authorization DenialRenderer (S09 Enforcement plan: 12/20)
What does this MR do?
Adds npm.DenialRenderer, the npm format's implementation of the authz.DenialRenderer seam: a masked 404 rendered as the package's generic not_found envelope, 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 npm 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 delegates to auditSearchNotFound, the package's only producer of the generic not-found envelope, so byte-identity with the genuine response is structural instead of coincidental. The repository name the seam supplies is deliberately ignored: the masked envelope is tier-generic per the spec (npm.CodeNotFound), so empty and non-empty names must yield identical bytes, and a test pins exactly that.
Three additions join the package's error taxonomy in transport.go: CodeServiceUnavailable (the authorization layer's fail-closed 503, distinct from CodeUpstreamUnavailable, which attributes a failure to a remote repository's upstream), and the MsgAccessDenied / MsgServiceUnavailable closed-set messages. CodeForbidden is now shared by the suspended-namespace write gate and the authorization denial, carrying a different message for each.
Decisions worth a reviewer's attention
Retry-Afteris 5 seconds, mirroring the shipped container/OCI renderer and the maven and management siblings. The constant's comment states the relation a reader can check — 5s equalsdefaultIAMTimeout(internal/config/iam.go) and exceedsdefaultGLAZTimeout(internal/config/glaz.go). 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 and the comment points at the management sibling, which carries the tracking pointer for rewording the spec.- The masked 404 is the generic
not_foundenvelope, not a tier-accurate one. That is the spec's explicit choice (S09's npm denial rendering namesnpm.CodeNotFound), and it is safe because a wiredAssemblyresolves and denies ahead of the npm resolver middleware, so "denied" and "does not exist" converge on the same bytes for every caller without read access. CodeServiceUnavailableholds no request-metric budget slot.requestCodeLabelsis asserted against the S11 Error Cases table and the new code has no row there;TestRequestCodeLabels_ExcludesUncountedCodesnow pins it out, so a budget slot for a series nothing increments cannot appear silently.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 all three shipped renderers apply, and pinned by a test.- The envelope is encoded after
WriteHeader, matching maven and the management sibling rather than the interface doc's build-first advisory. The envelope is three plain strings with no custom marshaler, so no post-commit failure path exists short of a gone client, which is unrecoverable either way. - Files beyond the plan step's
Files:entry. The step lists onlyinternal/format/npm/denial.goand its test; thetransport.goconstants are the taxonomy additions the renderer needs (the maven step made the same move inproblem.go), themetrics_test.gopin keeps the new code out of the metric budget, andmetrics.mdrecords the same fact in the catalog prose. Nothing from a later step is included.
Tests
Mirrors the maven renderer suite, with the method tables correctly absent — npm's error writers have no method branch, so a method axis would assert nothing:
- The masked 404 is compared against a genuine response captured by driving the real dispatcher — status, the committed header map (
Result().Header, the post-commit snapshot), and the body — with a pinned correlation id sorequest_idcompares non-trivially, the wire literalsnot_found/not foundpinned by value, and an assertion that the parser-recovered name is never echoed. - Empty and non-empty repository names are asserted byte-identical, anchored on a required 404 so a silent no-op cannot pass.
- The 403 is compared for shape parity against the genuine suspended-namespace 403: status, headers, and envelope code, with the denial message pinned by value ("access denied").
- The 503 pins
Retry-After: 5exactly,Cache-Control: no-store, the wire literalservice_unavailable, and a static message naming no dependency, permission, or policy. var _ authz.DenialRendererpins the structural implementation at compile time; there is no production wiring to catch signature drift until the npm wiring step lands.
The composition-level test proving the wiring passes this renderer rather than another belongs to the npm wiring step, which owns the wiring.
Diff size
239 lines added, 23 removed, across 5 files: 41 in internal/format/npm/denial.go, 163 in internal/format/npm/denial_test.go, and small supporting edits in transport.go, metrics_test.go, and metrics.md. Under the reviewable-LOC threshold, so no split justification applies.
e2e scenarios
The npm catalog's Access control rows stay blocked: the plan moves them off blocked in the npm wiring step, which is the step that makes denial behavior reachable. This MR changes no request behavior.
Related to #854 (closed)