feat(authz): add the Maven RouteParser (S09 Enforcement plan: 7/20)
🎯 What
Adds internal/authz's Maven RouteParser: mavenRouteParser.Mine claims paths whose post-slug segment is the literal maven (derived from mavenroute's own constants), Parse delegates to mavenroute.ParseRoute and maps GET/HEAD to read_artifact and PUT to create_artifact; every other method is RouteUnmapped (403) and an unparsed path under the mount is RouteUnrecognized (404), both returning the zero ParsedRoute. NewMavenMiddleware wires the parser into the shared Assembly with read_artifact as the masking follow-up action, mirroring NewOCIMiddleware. authz-format-boundary's allow list gains the mavenroute leaf.
Nothing wires the constructor in this MR. The holder, the adapter, and the mux-registration wrap are step 9 of the plan, and NewMavenMiddleware's doc records that the wrap belongs only at the maven.RoutePattern registration: Mine claims by segment position, not a path prefix, so a wider mount would claim other formats' paths.
💭 Why
Step 7 of the S09 enforcement plan. The container surface proved the seam contract (RouteParser + leaf grammar + DenialRenderer + wiring); Maven supplies its parser here, consuming the mavenroute leaf step 6 landed and the per-surface masking action step 5 parameterized. Maven's protocol has no delete operation, so no route maps delete_artifact — artifact deletion is S17 (rest-management-api) territory.
🔍 Design notes
- Decoded-path containment is stated and pinned, not assumed.
Minereads the decodedr.URL.Pathwhile the mux routes on the escaped path, so an encoded slash makes the two disagree. Three divergence shapes fall into two containment stories, and the body comments name each. A slash in the slug either moves the literal offparts[LiteralPos]or emptiesparts[0]—Mineis false and the request passes through unchecked — or splits the slug with the literal still in place, soParseauthorizes a pair the mux does not serve; all three need a slug holding/, which the namespace-slug charset forbids (validator plus thecheck_namespaces_slug_formatDB CHECK). A slash in the repository name keepsMinetrue but authorizes a different repository than the dispatcher serves — contained only by the management API's repository-name regex, with no DB charset constraint behind it. Tests pin the decoded forms, andTestMavenRouteParser_MineCoversParseRoutepins theMine ⊇ ParseRoutesuperset property with a vacuous-pass guard. RouteUnmappedbefore the dispatcher's 405. Methods the dispatcher would answer 405 are denied 403 ahead of it, matching shipped OCI behavior per S09 (authorization).- The masking-action table moved to a format-neutral home.
TestFormatMiddleware_MaskingReadActionand itsformatMiddlewareFuncshape move fromoci_test.goto the newformat_masking_test.goand gain the Maven row; the table was built to hold one row per format, and its file was OCI-named only because OCI landed first. The move is verbatim apart from the review round, which addednewTestAssemblyto the same file and moved the Basic-scheme request helper tomiddleware_test.goasauthenticatedBasicRequest. The Maven row uses PUT deliberately — Maven's only mapped non-read action — and that Basic helper, because the Maven mount challenges Basic, not Bearer.
⚠️ Plan drift
- The plan's
Files:list namedinternal/authz/maven.goand.golangci.yamlonly; the masking-table relocation addsformat_masking_test.goand the matching trim ofoci_test.go, and the review round addsmiddleware_test.go(the auth-request helper both suites now share) plus a one-line comment fix inmavenroute_test.go. - The plan asked for the
authz-format-boundarydescupdate; the landed wording enumerates both allow-listed leaves rather than naming one, because thedescis the literal message depguard prints detached from the file. Step 11 (npm) adds its leaf to the same two lines. - The
mavenroute-stays-a-leafcomments and theauthzprobe comment described this import as future work; this MR is the import, so they flip to present tense here, per the comment-accuracy guardrail.
🧪 Testing
| Plan acceptance criterion | Covered by |
|---|---|
GET and HEAD map read_artifact, PUT maps create_artifact |
TestMavenAction — full method matrix, unmapped rows asserting the empty action |
Every other method returns RouteUnmapped |
TestMavenAction unmapped rows; TestMavenMiddleware_ScopeAndFailClosed DELETE/POST/PATCH → 403 |
An unparsed path under the mount returns RouteUnrecognized |
TestMavenRouteParser_Parse_NonMatchedOutcomes — including Parse-level empty-component and wrong-literal rows |
A path outside the mount is not Mine |
TestMavenRouteParser_Mine — 12-row boundary table (empty path, root, empty slug segment, mavenx, decoded escaped-slug form) |
| No non-matched outcome carries a repository name | TestMavenRouteParser_Parse_NonMatchedOutcomes asserts the zero ParsedRoute on every row |
Constructor supplies read_artifact as the masking action |
TestFormatMiddleware_MaskingReadAction, Maven row |
go test ./... green, golangci-lint run 0 issues (default and --build-tags=integration invocations), the lint:comment-caps check passes at the tip, and the depguard:negative-control probes still fire under the widened allow list.
e2e scenario catalogs: unchanged, deliberately. The parser is reachable from no request path in this MR; the Access control rows in docs/testing/e2e/maven.md (including e2e.maven.access.reader-cannot-deploy, which covers the GET-allowed/PUT-denied contract) move off blocked in step 9, the step that makes Maven enforce.
📏 Diff size
+525/−108 across 9 files, past the 500-line justification threshold — but ~75 of the added lines are the relocated masking-action table out of oci_test.go, so the net-new reviewable count is ~450: 75 lines of production Go in one new file, ~430 of tests, and the .golangci.yaml allow-list line plus comment retensing. Splitting the parser from the tables that pin it would raise the count a reviewer holds in mind rather than lower it.
mavenroute duplicates the mount shape maven.RoutePattern declares in the dispatcher; the duplication and its reduction moves are tracked in #392, and step 9 pins the pair with a parity assertion.
Related to #849 (closed)