S09 enforcement 19/20: permission-filtered repository listing
Step 19 of 20 of the [S09 enforcement plan](https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/plans/2026-08-21-s09-authorization-enforcement.md#step-19-permission-filtered-repository-listing).
## :dart: Scope
The two-branch listing flow of the spec's Repository listing section.
## :link: Dependencies
Blocked by:
- [step 3](https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/845) — GLAZ BatchCheck client method
- [step 17](https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/859) — management enforcement at the slugMiddleware seam
- [step 18](https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/860) — candidate repository enumeration
Blocks:
- [step 20](https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/862) — fail-closed boot and stub retirement
## :open_file_folder: 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.
## :white_check_mark: 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.
## :test_tube: 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.
### :warning: 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.
### :mag: 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.
### :straight_ruler: 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.
### :twisted_rightwards_arrows: The plan text diverges from the amended spec
S09 gained a `Permission verdicts` design section and acceptance criteria 21–27 in https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/2125 (merged 2026-08-31, a61fa9c69), 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 https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/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 (https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/2166), 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.
1. **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.
1. **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.
## :books: References
- Plan: [`docs/plans/2026-08-21-s09-authorization-enforcement.md`](https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/plans/2026-08-21-s09-authorization-enforcement.md#step-19-permission-filtered-repository-listing)
- Spec: [S09 (authorization)](https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/specs/S09-authorization.md)
- Parent work item: https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/652
- MR title must end with the literal marker `(S09 Enforcement plan: 19/20)`
Related to https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/652
task
GitLab AI Context
Project: gitlab-org/ops/artifact-registry
Instance: https://gitlab.com
Before proposing or making any changes, READ each of these files and FOLLOW their guidance:
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/CONTRIBUTING.md — contribution guidelines
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/README.md — project overview and setup
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/AGENTS.md — AI agent instructions
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/CLAUDE.md — Claude Code instructions
Repository: https://gitlab.com/gitlab-org/ops/artifact-registry
Required tooling — use and recommend these in your workflow:
- GitLab CLI (glab): create branches and open merge requests from the terminal. https://gitlab.com/api/v4/projects/34675721/repository/files/README.md/raw?ref=HEAD