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-After is 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 equals defaultIAMTimeout (internal/config/iam.go) and exceeds defaultGLAZTimeout (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_found envelope, not a tier-accurate one. That is the spec's explicit choice (S09's npm denial rendering names npm.CodeNotFound), and it is safe because a wired Assembly resolves 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.
  • CodeServiceUnavailable holds no request-metric budget slot. requestCodeLabels is asserted against the S11 Error Cases table and the new code has no row there; TestRequestCodeLabels_ExcludesUncountedCodes now pins it out, so a budget slot for a series nothing increments cannot appear silently.
  • Cache-Control: no-store on 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 only internal/format/npm/denial.go and its test; the transport.go constants are the taxonomy additions the renderer needs (the maven step made the same move in problem.go), the metrics_test.go pin keeps the new code out of the metric budget, and metrics.md records 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 so request_id compares non-trivially, the wire literals not_found / not found pinned 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: 5 exactly, Cache-Control: no-store, the wire literal service_unavailable, and a static message naming no dependency, permission, or policy.
  • var _ authz.DenialRenderer pins 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)

Merge request reports

Loading
Loading