feat(managementapi): serve repository detail permission verdicts (S09 Verdicts plan: 5/10)

What changes for a caller

GET /api/v1/{slug}/repositories/{repository_name} takes a new include_permissions query parameter. With include_permissions=true the 200 gains a permissions object: the calling credential's allow-or-deny verdict for each of the nine actions the repository level binds.

{
  "name": "backend-images",
  "format": "oci",
  "permissions": {
    "read_repository": true,
    "update_repository": true,
    "delete_repository": false,
    "create_repository_upstream": false,
    "update_repository_upstream": false,
    "delete_repository_upstream": false,
    "read_artifact": true,
    "create_artifact": true,
    "delete_artifact": false
  }
}

Without the parameter, or with include_permissions=false, nothing changes: no permissions key, and no authorization work beyond what the route already did. Any other value answers 400, and so does a repeat of the parameter.

The repeat rejection lands in the shared parseEnumParam helper rather than in this route, so it also reaches include_referrers, format, kind, sort and order on the list endpoints. Those parameters previously took the first occurrence and dropped the rest; a repeat of any of them now answers 400, matching what destructiveIntent already does for destructive.

The object always carries the whole set, so a missing action is never a denial. Verdicts tell the UI which controls to render; they grant nothing, and every real request is still authorized on its own.

Why it is safe

The evaluator runs last, after the repository row resolves. A name that does not resolve, or a remote row whose parent vanished under the settings read, keeps its existing masked 404 and costs no tuple read, so the parameter opens no way to ask about a repository the caller cannot already see. On a repository the caller can read, a bad parameter value is a 400; on one they cannot, the 404 is unchanged.

Every failure fails closed. An evaluator error answers the surface's 503, an abandoned context writes nothing, and a batch that comes back short — or a set trimmed below the action set — is the logged 500 rather than a partial permissions object a caller would read as denials. No path renders a guessed verdict.

unconfiguredVerdictEvaluator gains a WARN it never had. A deployment with no iam: block answered 503 with a Retry-After and left no server-side record, which reads as a transient outage rather than the permanent misconfiguration it is. This is the first caller that can reach that arm. Logged once per process.

Spec coverage

Spec: S09 Authorization, Permission verdicts and API Contracts.

Criterion Tests
22 detail verdicts TestDetailHandler_IncludePermissions_CarriesTheRepositoryActionSet (hosted and remote arms), TestReadHandlersIntegration_DetailPermissions
24 opt-out is inert TestDetailHandler_WithoutIncludePermissions_RendersNoVerdicts (absent and false)
27 validation precedes evaluation TestDetailHandler_NonBooleanIncludePermissions_Returns400, plus a contract_test.go sweep row
Evaluation: a failure is never guessed TestDetailHandler_VerdictFailure_FailsClosed — 503 envelope, abandon writes nothing, and the short/trimmed-batch 500
Masking ordering TestDetailHandler_UnresolvedRepository_EvaluatesNoVerdicts
API contract TestContract_GetRepository_OptsIntoPermissions

The evaluator fake records every batch it is handed, so the cases assert the query the handler builds — object id, namespace and organization ancestors, whole action set — and not only the rendered body, which is identical for a query naming the wrong organization.

Siblings and merge order

All four open siblings were merge-tree'd against this branch's head b0db8cd0b9 and the union built, vetted and tested green.

MR Shares Verdict
!2312 (merged) (step 9, cache headers) api/openapi/v1.yaml, handler.go Clean. It stamps other operations' 200s and makes the header components required: true; this MR stamps only the getRepository block. Union green.
!2295 (merged) (step 3, list envelope) api/openapi/v1.yaml, contract_test.go Clean. Disjoint operation blocks; the sweep rows are additive. Union green.
!2254 (merged) (enforcement step 19) handler.go, read_handlers_integration_test.go Clean. Union green.
!2224 (merged) (namespace connection test) handler.go, api/openapi/v1.yaml, wire_management.go Conflicts, but not with this branch: !2224 (merged) already conflicts with main alone on docs/dev/bruno.md, handler_test.go and wire_management_test.go, none of which this MR touches. It needs a rebase either way.

Merge order this MR depends on. The getRepository 200 declares Cache-Control and Vary, but the middleware that sends them lands in !2312 (merged). Until !2312 (merged) merges, the contract names two headers the response does not carry. Nothing breaks — no test asserts the wire, and shared caches do not store responses to Authorization-bearing requests by default — but the document is ahead of the code, and !2312 (merged) is what closes it. Merging !2312 (merged) first also works; the two are independent.

Whichever of this MR and !2295 (merged) lands second inherits the other's contract_test.go sweep rows; they are appends at different anchors.

e2e scenarios

No catalog change. The four format catalogs cover artifact journeys and none reads the repository detail's permissions; the management-API catalog carrying the verdict scenarios lands with the namespace-endpoint work, per the plan's testing strategy.

Notes for the reviewer

  • writeRepositoryResourceWithSettings collapses into writeRepositoryResource, which now takes the settings map and the verdict set. Two call sites move. Steps 6 and 8 will want the same writer, so the rename is worth knowing about before they rebase.
  • handleDetail's remote-settings arm moves to detailSettings so both row kinds take one write call. That extraction is what let the verdict call sit last without duplicating the write.
  • The WARN in wire_management.go is outside this step's planned file set. It is here because this MR is the first thing that can reach that arm; happy to split it out if you would rather.

Related to #670 (closed)

Edited by João Pereira

Merge request reports

Loading
Loading