feat(managementapi): add the authorization DenialRenderer (S09 Enforcement plan: 15/20)
What does this MR do?
Adds managementapi.DenialRenderer, the management API's implementation of the authz.DenialRenderer seam: a masked 404 byte-identical to the surface's own genuine not-found, a generic 403 naming no permission, and a fail-closed 503 carrying Retry-After and Cache-Control: no-store, all rendered through the shared transport.Envelope.
The renderer is not wired yet. The enforcement path that calls it arrives with the management route bindings and the slugMiddleware seam, so nothing reaches this code at request time on main after this merges. That is why the step carries no dependency.
Both masked-404 branches reuse the surface's existing writers rather than rebuilding envelopes, which is what makes the byte-identity structural instead of coincidental:
- An empty repository name renders
writeNamespaceNotFound's envelope. Per the seam's contract an empty name means the route resolved no repository, which on this surface is a namespace-scoped route, and the namespace not-found is that route's own genuine answer. The step's acceptance names only the repository comparison; this branch extends it so a denied namespace-scoped request is indistinguishable from an absent slug. - A non-empty name renders
writeRepositoryNotFound's envelope.
Decisions worth a reviewer's attention
Retry-Afteris 5 seconds, mirroring the shipped container/OCI renderer's constant. Nothing about that value is deferred, and the spec's cross-surface sentence is what needs the edit rather than the code. That sentence asks the hint to exceed "the longest default timeout of the fail-closed dependencies (IAM, GLAZ, the datastore resolve)", but the service sets nostatement_timeouton the datastore — a fact the datastore package records in several places,internal/datastore/repositories.goandinternal/datastore/reconcile_namespace.goamong them. The named set therefore has no finite maximum, so no value can satisfy the sentence as written, and the superlative it asks for cannot be evaluated at all. What the constant can state, and does, is the relation a reader can check: 5s equalsdefaultIAMTimeoutand exceedsdefaultGLAZTimeout. It deliberately does not repeat the container/OCI precedent's own "set above the longest of their default timeouts (IAM's 5s)" claim, which is false at 5s against 5s. Rewording the spec sentence is the spec author's call, so it is flagged here rather than edited from a step MR.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 the container/OCI renderer applies, and pinned by a test so it cannot be dropped silently.- The 503 is deliberately not shape-identical to the surface's genuine 503s, which set neither header. Only the masked 404 carries a shape obligation; the 403 and 503 need an envelope so they are not distinguishable as bodyless responses, which is a weaker requirement than matching a genuine response.
Tests
Mirrors the container/OCI renderer suite with no dropped assertions, and adds two beyond it:
- Both masked 404s are compared against a genuine response captured by driving the real handler, not by re-deriving an envelope — status, the committed header map, and the body, with a pinned correlation id so
request_idcompares non-trivially. Each also asserts the rendered response is a well-formed envelope and pins its branch's message, so the branch is covered directly rather than through the two messages happening to differ. - The 403 is compared for shape parity against the genuine suspended-namespace 403: status, headers, and envelope code, deliberately not the message, since the suspension names its cause while a denial withholds it.
var _ authz.DenialRendererpins the structural implementation at compile time. There is no production wiring to catch a signature drift until the enforcement step lands.
The composition-level test that no constructor signature can replace — proving the wiring passes this renderer rather than another — belongs to the enforcement step, which owns the wiring.
Diff size
182 lines added, none removed: 46 in internal/managementapi/denial.go and 136 in internal/managementapi/denial_test.go. Well under the reviewable-LOC threshold, so no split justification applies.
e2e scenarios
No catalog under docs/testing/ covers the management API, so no scenario is added or affected. The renderer's behavior is pinned by its package tests.
Related to #857 (closed)