fix(managementapi): gate repository listing on organization membership

🔐 What this changes

GET /api/v1/:slug/repositories was the one management route whose binding ran no organization comparison. Its scopeListingFilter arm evaluated only the anonymous pre-gate and deferred every other decision to the handler, so the route's status code followed namespace resolution rather than the caller's relationship to the namespace.

That arm now delegates to authorizeOrgMembership — the same function the namespace details endpoint's binding already calls — so both carriers of ADR-020's organization comparison share one implementation rather than two that can drift.

📊 Behaviour, by caller

Caller Before After
Of the owning organization, no role 200 {"repositories":[]} unchanged
Of the owning organization, repository grants filtered list unchanged
Of the owning organization, namespace role full list unchanged
Of another organization 200 {"repositories":[]} 404, byte-identical to the unknown-slug 404
Of the owning organization but homed in another one filtered list, or the empty 200 404; the token names their home organization, so the comparison cannot admit them
Non-organization origin (federated) 503 from the grant drain 404, refused before the drain
No identity holder reached the handler 404 at the anonymous pre-gate
Namespace with no canonical organization anchor reached the handler 404, fail-closed

A cross-homed member is the one legitimate caller this refuses: the exchange fixes gitlab.origin_id at mint from the acting user's home organization, so a member of a second organization presents a token naming the first. The namespace details endpoint has refused them since it adopted the comparison; docs/specs/S09-authorization.md names the class, and nothing in this MR can admit them — a home-organization change or a claim change in the exchange can.

The first three rows are the point: ADR-021's List-operations rule makes the empty 200 the landing experience for a principal of the owning organization holding no role (ADR-014), and the grant drain keeps deciding what that caller sees. The gate narrows who reaches the drain, not what the drain answers.

⚖️ Why this shape

ADR-021 already ties the two routes together, reading the namespace route as leaking nothing the list route does not. That holds once both apply the comparison, so this is a conformance fix rather than a new policy, and no ADR amendment is owed. docs/specs/S09-authorization.md is amended instead: its bindings table carved the listing filter out with no organization gate.

Because the gate sits at the slugMiddleware authorize step it precedes parseListQuery, so a refused caller's malformed listing parameter answers the route's 404 rather than a 400.

Closing #1009 is a pre-launch dependency: the pre-lock test namespaces it inventories carry a decimal Rails id in their anchor, and this gate answers them 404 on every caller until they are discarded. No customer rows are in that set.

📏 Size, per the development model

611 insertions across 14 files, which crosses the 500 reviewable-LOC threshold. Split by group:

Group Added Deleted
Production Go 9 13
Test Go 536 22
Docs 66 42

The production change is a net -4 lines: one delegation plus comment rewrites, with binding.go and list_authz.go comment-only. The test half is the whole cost, and it is not padding:

  • list_organization_gate_test.go (404) — the gate suite: seven caller-and-anchor classes, 404 byte-identity against the unknown-slug 404, the gate-before-parseListQuery ordering pin, a negative control proving the real comparison decided, and the three unenforced-posture rows the mirrored suite carries.
  • list_organization_gate_integration_test.go (50) — the integration twin, which the mirrored namespace-details suite has.
  • The remainder repairs test doubles that were latently wrong before this change: fakeAllowAllAuthorizer inherited the real comparison through an embedded productionOrgComparison that no listing test had ever reached, so it becomes genuinely allow-all and declares itself test-only, while namespaceDetailAuthorizer carries the real comparison for the suites that assert it.

Splitting would not help. docs/specs/S09-authorization.md (61) could land as a separate spec MR, leaving ~550 — still over the threshold. Separating the suite from the gate would land either a gate with no coverage or tests for behaviour that does not exist yet.

🧪 Verification

  • Unit suite, and the integration suite under -tags integration (which CI lint cannot see, so golangci-lint run --build-tags=integration was run explicitly).
  • scripts/ci/check-comment-caps.sh --base origin/main.
  • The gate suite was falsified: reverting the arm makes seven of its tests and subtests fail, and breaking params.IDs in restrictCandidatesToGrants makes the restriction assertion fail.
  • docs/testing/e2e/management.md gains a Repository listing section with the cross-organization, unknown-slug and same-organization-no-role scenarios.
  • No api/openapi or api/bruno change is owed: listRepositories already declares 404 and the change adds no operation.
  • No run-recipe change is owed: the local rig boots authorization.unenforced, where the gate denies nobody.

Related to https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/1271

Edited by David Fernandez

Merge request reports

Loading
Loading