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_000counts 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 onIDs(!2425 (merged)) so that net never fires for this caller.maxLookupResourcesPagesbounds a server that keeps handing back a token.lookupResourcesDrainBudgetbounds 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
errListingUpstreamUnavailableand 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
unrestrictedand runs the sweep exactly as before. The namespaceCheckreads the cached relationship path while the drain and the chunk reads do not, so a grant written inside one cache TTL denies at theCheckwhile 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
IDsis 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
listingFilterSubjectrather 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