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
writeRepositoryResourceWithSettingscollapses intowriteRepositoryResource, 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 todetailSettingsso 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.gois 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)