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":[]} |
|
| Of the owning organization, repository grants | filtered list | |
| Of the owning organization, namespace role | full list | |
| 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,404byte-identity against the unknown-slug404, the gate-before-parseListQueryordering 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:
fakeAllowAllAuthorizerinherited the real comparison through an embeddedproductionOrgComparisonthat no listing test had ever reached, so it becomes genuinely allow-all and declares itself test-only, whilenamespaceDetailAuthorizercarries 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, sogolangci-lint run --build-tags=integrationwas 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.IDsinrestrictCandidatesToGrantsmakes the restriction assertion fail. docs/testing/e2e/management.mdgains aRepository listingsection with the cross-organization, unknown-slug and same-organization-no-role scenarios.- No
api/openapiorapi/brunochange is owed:listRepositoriesalready declares404and 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