feat(managementapi): bound the repository list to the caller's own grants (S09 LookupResources plan: 3/3)

The repository listing route's denied branch now drains the caller's own grants from IAM once per request (LookupResources) and restricts candidate enumeration to the repository ids it returned. A caller with no namespace role pays for their own grants rather than for the namespace's size.

BatchCheck stays the verdict authority. The drain only shrinks the candidate set; it never allows a row the per-candidate check denies.

Depends on !2425 (merged) and targets its branch: the IDs restriction this MR passes to the store lands there, so merging out of order does not build. Retargets to main when !2425 (merged) merges.

Notes for the reviewer

  • The drain has three ceilings, all runaway guards. maxLookupResourcesGrants = 2_000 counts distinct drained object ids of any type (deduplicated first; grants on other namespaces spend the same budget), starts conservative per review, and stays under the datastore's independent 5,000 net on IDs (!2425 (merged)) so that net never fires for this caller. maxLookupResourcesPages bounds a server that keeps handing back a token. lookupResourcesDrainBudget bounds the drain's wall clock against the re-armed response write deadline, so a refusal is always still writable. Crossings are logged with the ceiling kind and the measured counts as structured fields, so raising a value later is an evidence-driven one-constant change.
  • Every drain failure answers 503, never a partial page. A refused self-lookup, a transport failure, and either ceiling all reach the branch as errListingUpstreamUnavailable and render through the existing listing-unavailable arm. A page filtered on a truncated grant set is indistinguishable from a complete one, so truncating would hide readable repositories behind a 200.
  • The ancestor escape hatch is load-bearing, not caution. A grant naming the namespace or its organization ancestor answers unrestricted and runs the sweep exactly as before. The namespace Check reads the cached relationship path while the drain and the chunk reads do not, so a grant written inside one cache TTL denies at the Check while both uncached reads still see it. Restricting on it would answer that caller an empty page. The hatch is checked per tuple on every fetched page, and a whole page is scanned before the grant ceiling is tested, so an ancestor grant anywhere up to and including the page that crosses the ceiling still lifts the restriction; past a ceiling refusal no further page is fetched.
  • An empty drain is an empty 200, not a denial. No grant means no candidate, so the branch answers before enumerating: an explicitly empty IDs is rejected by the store and a nil one reads as unrestricted, so neither can express "no candidate". An unresolvable subject arrives as the same empty page and is not a failure.
  • The identity guard moved ahead of enumeration. The drain resolves the caller's own subject, so a request reaching the branch without an identity now fails closed at listingFilterSubject rather than inside the chunk evaluation. Its subject travels under the caller's forwarded JWT, which IAM admits only at organization origin.

Diff is past 500 reviewable LOC, split by group:

Group Added Removed
Source (internal/managementapi, cmd/artifact-registry) 232 41
Tests 1148 57
Docs 1 1

Splitting would not help: the seam threading (Deps, authzWiring, uncachedIAMClient) and the drain that consumes it have to land together, or cmd/artifact-registry will not build. The test half is the bulk, and it splits by level rather than by concern.

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

Edited by João Pereira

Merge request reports

Loading
Loading