feat(managementapi): serve repository list permission verdicts (S09 Verdicts plan: 6/10)

Adds include_permissions to the repository list. When set, the response carries the calling credential's allow-or-deny verdict per action: the namespace action set at the top level of the envelope, and each row's own repository action set beside it. The UI uses these to decide which controls to render; nothing is authorized by them, and every real request is still checked when it is made.

What changes for a caller

GET /api/v1/:slug/repositories?include_permissions=true

{
  "permissions": {"create_repository": true, "read_repository": true, "...": "..."},
  "repositories": [
    {"name": "backend-images", "...": "...",
     "permissions": {"update_repository": true, "delete_repository": false, "...": "..."}}
  ]
}

Without the parameter nothing changes: no permissions key anywhere, the same {"repositories": [...]} envelope, and no verdict evaluation at all. A value other than true or false, repeats included, returns 400 before any page read or GLAZ call. Pagination stays in the Link header, and a followed rel="next" carries the opt-in forward.

The whole page costs one batch: the namespace query leads, the page's rows follow in display order. A failure to evaluate answers 503 rather than a page with guessed verdicts, and a batch that does not answer every query with its whole action set is a 500 rather than a partial permissions object. Each row query carries its own ancestor and action slices, so an evaluator that writes through the batch cannot re-scope the rest of the page. The detail read's completeness check now shares the list's predicate, so a set of the right size keyed on actions the query never asked for is a 500 there too, instead of a permissions object under the wrong keys.

Spec coverage

Spec: S09 Authorization, Permission verdicts and API Contracts.

# Criterion Tests
23 List verdicts under a namespace role TestListHandler_IncludePermissions_CarriesNamespaceAndRowVerdicts, ..._EvaluatesThePageInDisplayOrder, ..._EmptyPage_CarriesNamespaceVerdictsOnly, TestReadHandlersIntegration_ListPermissions
24 Opt-out is inert (list arm) TestListHandler_WithoutIncludePermissions_RendersNoVerdicts
25 Malformed batch fails closed TestListHandler_VerdictFailure_FailsClosed, TestListHandler_ShortVerdictBatch_Returns500
26 Cache-private reads TestContract_ListRepositories_OptsIntoPermissions pins the 200's header declarations. The wrap that sends them is !2312 (merged); see Merge order.
27 Validation precedes evaluation TestListHandler_IncludePermissions_ParameterFailuresPrecedeEvaluation, contract row list 400 invalid include_permissions
18 A filtered-in row carries permissions The filter itself is the enforcement plan's. TestListHandler_Authz_Filtered_VerdictsFollowTheRenderedRows pins the part this MR owns: on the filtered branch the batch's row queries match the rendered rows, in order.

Contract: TestContract_RepositoryList_DeclaresOptionalNamespacePermissions pins that the envelope carries the namespace set and each row the repository one, neither required.

Merge order

The list 200 now declares Cache-Control: private, max-age=0 and Vary: Authorization, and no code sends either until !2312 (merged) lands the NewHandler wrap. The plan gives each surface step its own op block and !2312 (merged) the wrap, so this is the intended split — but while !2312 (merged) is open the published contract advertises a per-principal cache directive the service does not emit, on a body that now varies by credential. Once !2312 (merged) merges, the wrap stamps both headers on every management read and the declaration is satisfied.

The 503 declaration

The list operation was the only management read op not declaring 503, while its handler could already answer one from the listing filter's unavailable arm. This MR adds a third producer, so the declaration lands here and is pinned by the contract test.

Sibling union check

MR Files shared with this one Overlap
!2314 (merged) (step 8) v1.yaml, handler.go, contract_test.go, verdict_contract_test.go Disjoint regions: it owns the /namespace block and the Namespace schema, this one the repositories-collection GET and RepositoryList. The Go files take additive, differently-named entries.
!2312 (merged) (step 9) v1.yaml, handler.go It stamps headers on the op blocks no sibling edits and wraps NewHandler; this one stamps the list op's own 200 and edits the handleList registration line.
!2224 (merged) (S17 connection test) v1.yaml, handler.go, read_handlers_integration_test.go It adds the /connection-test block, its own route line, and seams in integrationDeps; this one appends a test function and edits an unrelated op.

Size

801 changed lines, past the 500 guardrail: 142 source Go, 644 test Go, 15 OpenAPI and Bruno. The source half is one handler arm and would not split usefully — the parameter, the batch, and the envelope field are one behavior, and splitting them would land a parsed parameter that does nothing. The test half is where the size is, and it is deliberate: the batch-shape, per-row-distinctness, filtered-branch, and incomplete-batch cases each pin a failure that renders a plausible wrong answer rather than an error.

e2e

No scenario added or affected. The four e2e.<format>.setup.create-repository scenarios read the list without the parameter and are unchanged by an opt-in that defaults off. The verdict-opt-in scenarios land with the management catalog in !2314 (merged).

Related to #670 (closed)

Edited by João Pereira

Merge request reports

Loading
Loading