feat: namespace details endpoint (S09 permission verdicts plan: 7/10)
What
Adds GET /api/v1/{slug}/namespace: any authenticated member of the organization that owns the namespace can read its slug and created_at — no role assignment needed. A caller from another organization gets the same 404 an unknown slug gets, so the endpoint never reveals whether a slug exists. No credential means a 401.
The membership check runs only when the boot enforces authorization; an unenforced boot serves the route without deciding anything, like every other management route (the spec's boot posture). Denials are counted (denials_total{org_membership}) and logged with the denying arm's reason.
Spec amendments (same MR)
idis dropped from the body: ADR-022 keeps the namespace UUID internal ("never exposed externally"), so it wins over the spec's repository-id precedent, per operator decision. The field set is additive (ADR-009), soidcan return later behind an ADR-022 amendment.- The observability section gains
org_membershipand the previously omittedescaped_separator, andorganization_ancestor_skipped_total's definition widens to cover the gate's derivation, which runs no tuple read.
Size (guardrail 18)
+920/−53 across 27 files. The overage is mostly tests: 131 lines are source, 684 are the tests that pin the gate's arms, and 105 are contract files (OpenAPI, Bruno, spec). Splitting would not help: the handler, its gate, and their contract tests only make sense reviewed together, and the plan already forecasts this as the one step past the 500-LOC guardrail.
Notes
- e2e catalogs (guardrail 12): no scenario added; the endpoint's entry lands with Step 8 (verdicts + cache headers), which also stamps the
/namespace200's cache-header$refs. docs/dev/bruno.md: deliberately deviates from open sibling !2224 (merged)'s paragraph; whichever lands second merges it.- Posture asymmetry:
Enforcing() == falsealso covers the interim no-iam:boot, which already allow-alls every management route; Step 4's verdict seam fails its third arm closed. Deliberate.
Spec coverage
Spec: docs/specs/S09-authorization.md. Rows owned by other steps or the enforcement plan say so; unlisted criteria are unchanged from the enforcement plan's coverage.
| # | Criterion | Tests |
|---|---|---|
| AC-1 | Anonymous deny | Enforcement plan; this step adds TestAuthorizeRoute_AnonymousOrgMembershipNeverCompares. |
| AC-3 | Read deny masks | Enforcement plan; this route: TestNamespaceDetail_CrossOrganizationCallerGetsTheUnknownSlugNotFound. |
| AC-7 | Target-anchored ancestor | Enforcement plan; the comparison reads the resolved anchor (same test). |
| AC-14 | Management point checks | TestAuthorizeRoute_EveryBoundRouteEnforcesItsPermission: org_member_only is allowlisted; an unlisted route carrying the kind fails the sweep. |
| AC-20 | Boot posture | Enforcement plan + Step 4; this step: TestNamespaceDetail_UnenforcedPostureDecidesNothing, TestEvaluatorEnforcing, TestAllowAllDecisionEvaluatorReportsUnenforced. |
| AC-21 | Namespace endpoint gate | TestNamespaceDetailIntegration_GateArms, TestNamespaceDetail_MemberOfTheOwningOrganizationReadsTheNamespace, TestNamespaceDetail_CrossOrganizationCallerGetsTheUnknownSlugNotFound, TestNamespaceDetail_NonOrganizationAnchorDenies, TestNamespaceDetail_EmptyEntityIDDenies, TestNamespaceDetail_RequestWithNoIdentityHolderDenies, TestNamespaceDetail_FederatedOriginDenies, TestAuthorizeRoute_OrgMemberOnlyFailsClosed, TestNewRouter_BindingsMatchTheGoldenTable. Verdict half: Step 8. |
| AC-24 | Opt-out is inert | Steps 5, 6, 8; this step pins the body's key set (slug/created_at). |
| E-4 | Management 404 identical to the genuine one | TestNamespaceDetail_CrossOrganizationCallerGetsTheUnknownSlugNotFound (full header set + body), TestNamespaceDetailIntegration_GateArms, TestNamespaceDetail_ResponsesMatchOpenAPIContract. |
| E-5 | No denial names the permission/role/policy | TestNamespaceDetail_NonOrganizationAnchorDenies; TestNamespaceDetail_DenialLogsWhichArmDenied pins the reason reaches the log, not the response. |
| E-6 | No denial echoes request-derived values | Cross-org 404 byte-identical to unknown-slug, headers included. |
| E-9 | One 404 for unknown slug and cross-org caller | Same two tests as E-4. |
| S-1 | Existence masking is shape-sensitive | Same two tests (status, headers, body bytes). |
| S-2 | Every denial is enveloped | The three deny-arm tests under AC-21. |
| S-3 | Fail closed everywhere | TestAuthorizeRoute_OrgMemberOnlyFailsClosed plus the deny-arm tests. |
| S-4 | Trusts nothing it did not derive | Anchor from the resolved namespace; a matching id under the wrong Origin still denies (TestNamespaceDetail_FederatedOriginDenies). |
| S-7 | Verdicts advisory and organization-bound | Binding half is this step; advisory half is Steps 5, 6, 8. |
Related to #670 (closed)