feat(managementapi): embed namespace verdicts behind include_permissions (S09 Verdicts plan: 8/10)
What changes for a caller
GET /api/v1/{slug}/namespace takes an include_permissions boolean. Ask for it and the response gains a permissions object saying whether this credential may do each thing the namespace allows:
{
"slug": "acme",
"created_at": "2026-05-02T09:14:00Z",
"permissions": {
"read_repository": true,
"create_repository": true,
"update_repository": false,
"delete_repository": false,
"create_repository_upstream": false,
"update_repository_upstream": false,
"delete_repository_upstream": false
}
}Leave the parameter off, or send false, and the body is exactly what it was before: no permissions key, and no verdict work at all. Any other value is a 400. The object always carries all seven actions, so a missing key never means "denied".
The verdicts are advisory. They tell a UI which controls to draw; the real request is authorized again when it is made.
Why it is safe
The verdict path sits behind the endpoint's existing organization gate, so an unknown slug and a caller from another organization both still get the same 404 before any of this runs. A bad parameter value on a namespace you cannot see is that 404, not a 400, so the parameter is not an existence probe.
The query names the namespace the middleware resolved and that namespace's own stored organization anchor, never anything from the caller's token. If IAM or GLAZ cannot answer, the response is the surface's 503 — never a 200 carrying guessed or all-false verdicts, which a client could not tell from real denials. The namespace UUID stays internal.
A dedicated security pass over this diff found no High or Critical issues and no ADR deviation.
Merge order with the cache headers
This MR adds the Cache-Control: private, max-age=0 and Vary: Authorization $refs to the /namespace 200, because their components are already on main and this operation's block is this MR's to edit. Nothing emits those headers yet: the middleware that stamps them is !2312 (merged). So between this MR merging and !2312 (merged) merging, the contract declares the two headers and responses do not carry them. !2312 (merged) closes that window, and its own tests assert the emission. The new contract test here is explicit that it checks the document, not the wire.
Spec coverage
Spec: docs/specs/S09-authorization.md. Only the criteria this step owns are listed; the rest belong to sibling steps or to the enforcement plan.
| # | Criterion | Tests |
|---|---|---|
| 21 | Namespace endpoint gate, verdict arms | TestNamespaceDetail_VerdictsComeFromTheTuplesRead (member with no assignment reads every action denied; a namespace role reads matching allows), TestNamespaceDetailIntegration_VerdictArms |
| 24 | Opt-out is inert, namespace arm | TestNamespaceDetail_VerdictOptOutEvaluatesNothing (absent and explicit false: no permissions key, evaluator never called) |
| 27 | Parameter validation precedes evaluation | TestNamespaceDetail_NonBooleanIncludePermissionsIs400 (1, 0, TRUE, True, yes, padded, empty; no batch recorded) |
| 5 | Infrastructure failure fails closed, verdict arm | TestNamespaceDetail_VerdictFailureRendersTheUnavailableEnvelope, TestNamespaceDetail_VerdictAbandonWritesNothing |
| 7 | Target-anchored ancestor | TestNamespaceDetail_VerdictQueryNamesTheNamespaceAndItsOrganization |
| 26 | Cache-private reads, contract half on this operation | TestContract_NamespaceOperation_OptsIntoVerdicts |
Three guards the seam owes but production cannot reach are pinned too: TestNamespaceDetail_UnusableVerdictAnswerIs500 covers an evaluator answering no set, one answering a set spanning no action, and one answering a set of the right size whose keys are not the actions the call asked for. All three were verified non-vacuous by reverting the guard and watching them fail.
Siblings and merge order
| MR | Shares | Merges with this branch |
|---|---|---|
| !2312 (merged) (plan 9/10, cache headers) | api/openapi/v1.yaml, internal/managementapi/handler.go |
Clean. The union builds, vets, and passes the package tests. |
| !2295 (merged) (plan 3/10, list envelope) | api/openapi/v1.yaml, internal/managementapi/contract_test.go |
Clean |
| !2297 (merged), !2268 (merged), !2266 (merged) | api/openapi/v1.yaml or handler.go |
Clean |
| !2224 (merged) | api/openapi/v1.yaml, handler.go |
Conflicts, but only on docs/dev/bruno.md and handler_test.go, which this branch does not touch. Both conflict against main on their own. |
| !2052, !1011 (closed) | docs/testing/e2e/README.md |
Conflicts on files this branch does not touch; both conflict against main on their own. |
No sibling touches namespace_detail.go, the /namespace path block, the Namespace schema, or api/bruno/management-api/namespace/.
E2E scenarios
Adds docs/testing/e2e/management.md with the namespace read arms and the verdict opt-in, opt-out, and invalid-parameter arms, plus its row in the catalog README. The existing four catalogs are per format and neither surface belongs to one.
Diff size
628 added lines, of which 60 are source (namespace_detail.go and one route line in handler.go) and 15 are OpenAPI. The rest is tests (503) and docs (48). Splitting would separate the tests from the code they pin, which the project asks against.
Related to #670 (closed)