S09 enforcement 19/20: permission-filtered repository listing

Step 19 of 20 of the S09 enforcement plan.

🎯 Scope

The two-branch listing flow of the spec's Repository listing section.

Blocked by:

  • step 3 — GLAZ BatchCheck client method
  • step 17 — management enforcement at the slugMiddleware seam
  • step 18 — candidate repository enumeration

Blocks:

  • step 20 — fail-closed boot and stub retirement

📂 Files

  • internal/managementapi/list_authz.go (Create): the namespace-level short-circuit, the chunked enumerate-evaluate-filter loop, and the filter-before-paginate ordering.
  • internal/managementapi/list.go (Modify): branch into the filter when the namespace-scoped Check denies.
  • cmd/artifact-registry/wire_management.go (Modify): supply the batch checker and the uncached relationship reader.

Acceptance

A principal holding any namespace-level role receives the full list with no per-row evaluation. A principal holding only a repository-level assignment on one repository receives exactly that one, with pagination applied after filtering so no page carries an invisible row and no page token skips a visible one. A principal with no assignment receives an empty 200. A ReadRelationships or BatchCheck failure on any chunk returns 503 and never a partial list. Chunks are bounded by both the confirmed IAM request limit and the GLAZ relationship cap. The format-filtered variants follow the same flow.

🧪 Tests

internal/managementapi/list_authz_test.go (new) for both branches, the empty case, the chunk-boundary case, the both-caps bound, and the failure-is-503 case; internal/managementapi/list_test.go (Modify) to pin that the namespace-allowed branch still paginates from the datastore unchanged.

⚠️ Two blocking external dependencies

  1. The deployed GLAZ sidecar must serve BatchCheck before this step merges. A sidecar still on v1.0.0 answers UNIMPLEMENTED, which the listing filter surfaces as a 503 on every filtered list. Confirm the deployed version before this MR opens; step 3's distinct UNIMPLEMENTED mapping is what makes a mismatch diagnosable if it slips through.
  2. The IAM ReadRelationships request limit must be confirmed before this step merges. S09's 9,998 chunk size is derived from a cap that no code in internal/iam enforces — ReadRelationships validates nothing at all, and the 10,000 figure traces to the comment on maxRecvMsgSize, a buffer-sizing justification. Confirm the service's real limit with the IAM team and size the chunk against it and the GLAZ relationship cap together. One repository can carry several assignment tuples, so a 9,998-object chunk can return more than 10,000 tuples: the chunk sizing must bound both.

🔍 Read through the client, not the cache

internal/iam's CachingResolver keys on principal plus the sorted object set plus kinds, and bounds its map by entry count. A listing chunk produces a single, near-unique key holding thousands of tuples, so filtered listing reads would evict the point-check entries the rest of the service depends on. This step reads through the underlying client rather than the cache.

📏 Size

Roughly 230 source and 550 test LOC. The chunked evaluator does not split out: its only consumer is this handler, and its correctness claim — pages contain only visible rows — is unverifiable without the pagination it feeds.

🔀 The plan text diverges from the amended spec

S09 gained a Permission verdicts design section and acceptance criteria 21–27 in !2125 (merged) (merged 2026-08-31, a61fa9c6), which also updated S17's list contract. The enforcement plan was written before that amendment, so the fields above are behind the spec in the ways below. The spec is the source of truth; read its criteria rather than the plan's step text when building this. The plan-side correction collects in #941, never in this step's MR.

  1. The list 200 is an envelope, not a bare array. S09's API Contracts and S17's list contract both specify {"repositories": [...]} on every call, whether or not verdicts are requested. The flip is step 3 of the permission-verdicts plan (!2166 (merged)), which owns internal/managementapi/list.go's envelope write and the OpenAPI RepositoryList schema. This step and that plan's steps 3 and 6 all modify list.go and the list operation block, on disjoint arms: visibility filtering here, the per-row permissions object there. Whichever merges second rebases over an additive union, so check git diff --name-only main...HEAD against the open verdicts MRs before opening this one.
  2. Criterion 25 names the listing filter explicitly. A BatchCheck response with a missing, duplicate, or unrequested result must answer 503 — never an implicit deny, never a silently dropped row — and results are matched on item_index and action rather than on order. The code for that already merged with step 3: fromProtoBatchCheckResponse in internal/authz/glaz/mapping.go validates every result against the request before mapping it, so this step inherits a test obligation rather than new logic. The Tests field above predates the criterion and does not list it.
  3. Criteria 17 and 18 were reworded. Criterion 17 now reads "no per-row visibility evaluation", because requested verdicts still evaluate the returned rows even under a namespace-level role — repository assignments are additive and can raise a row past the namespace role. Criterion 18 gained a clause that a filtered-in row carries its own permissions object, which is provable only once both this step and the verdicts plan's step 6 have merged; its integration case rides whichever lands second. The Acceptance field above still carries the pre-amendment flat wording.

📚 References

Related to #652 (closed)

Edited by David Fernandez