|
| 1 | +# SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +# Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 3 | +# |
| 4 | +# =========================================================================== |
| 5 | +# Verb Governance Spec — WORKED EXAMPLE for BoJ's HTTP surface |
| 6 | +# =========================================================================== |
| 7 | +# Phase A deliverable A3 (standards#96 / standards#91). |
| 8 | +# |
| 9 | +# This is the *example* policy. The live policy will be config/gateway-policy-boj.yaml, |
| 10 | +# added in a later phase. See: |
| 11 | +# - docs/integration/http-capability-gateway-boj-contract.md (the seam contract) |
| 12 | +# - docs/integration/http-capability-gateway-policy-authoring.md (authoring workflow) |
| 13 | +# |
| 14 | +# Conforms to DSL v1 (gateway audit §2). Validator invariants honoured: |
| 15 | +# * dsl_version is exactly the string "1" |
| 16 | +# * governance.global_verbs is a non-empty list of all-caps HTTP verbs |
| 17 | +# * every route has a non-empty `verbs` list |
| 18 | +# * exposure ∈ {public, authenticated, internal} |
| 19 | +# * stealth has boolean `enabled` and integer `status_code` in 100..599 |
| 20 | +# |
| 21 | +# Surface source: docs/specification/openapi.yaml, cross-checked against |
| 22 | +# elixir/lib/boj_rest/router.ex (per the plan's "surface drift" risk). Routes |
| 23 | +# declared in openapi.yaml but not yet wired in router.ex are governed anyway |
| 24 | +# (contract §8) so they are not silently exposed when implemented. |
| 25 | +# |
| 26 | +# --------------------------------------------------------------------------- |
| 27 | +# DEFAULT-DENY NOTE (must be confirmed in the Phase A manual verification) |
| 28 | +# --------------------------------------------------------------------------- |
| 29 | +# The gateway's three-tier lookup is exact → regex → global-fallback. A path |
| 30 | +# that matches NO route entry must be DENIED, not served as public via the |
| 31 | +# global-fallback rule. `global_verbs` below is the set of verbs the gateway |
| 32 | +# will route AT ALL (DELETE/PUT/PATCH/OPTIONS absent ⇒ globally denied — the |
| 33 | +# core verb-governance win of ADR-0004). When the gateway is first stood up, |
| 34 | +# the manual verification (authoring-workflow §6) MUST confirm that an |
| 35 | +# unlisted path does NOT fall through global-fallback to `public`. If the |
| 36 | +# gateway treats global-fallback as public-by-default, the live policy must |
| 37 | +# pin global exposure to deny / internal before go-live. This caveat is |
| 38 | +# recorded here deliberately rather than papered over. |
| 39 | +# =========================================================================== |
| 40 | + |
| 41 | +dsl_version: "1" |
| 42 | + |
| 43 | +governance: |
| 44 | + # Only GET and POST are used anywhere on BoJ's declared surface. Every other |
| 45 | + # verb (DELETE, PUT, PATCH, OPTIONS, HEAD, ...) is globally denied by omission. |
| 46 | + global_verbs: |
| 47 | + - GET |
| 48 | + - POST |
| 49 | + |
| 50 | + routes: |
| 51 | + # ---- Public (reachable pre-auth) ------------------------------------ |
| 52 | + - path: "/health" |
| 53 | + verbs: [GET] |
| 54 | + exposure: "public" |
| 55 | + name: "health-get" |
| 56 | + narrative: "Liveness/readiness probe. Must be reachable without trust by |
| 57 | + load balancers and Cloudflare health checks." |
| 58 | + |
| 59 | + - path: "/.well-known/boj-node-pubkey" |
| 60 | + verbs: [GET] |
| 61 | + exposure: "public" |
| 62 | + name: "node-pubkey-get" |
| 63 | + narrative: "X25519 node public key. Callers MUST fetch this before they |
| 64 | + can encrypt per-invocation credentials (docs/AUTH-DESIGN.adoc Option |
| 65 | + A), so it is necessarily pre-auth and public." |
| 66 | + |
| 67 | + # Deliberate public exception: external webhook ---------------------- |
| 68 | + - path: "/cartridges/ssg-mcp/webhook" |
| 69 | + verbs: [POST] |
| 70 | + exposure: "public" |
| 71 | + name: "ssg-mcp-webhook-post" |
| 72 | + narrative: "GitHub push-event webhook. The sender (GitHub) cannot present |
| 73 | + a gateway trust level, so this route is intentionally `public` at the |
| 74 | + gateway. Real authentication is the GitHub HMAC signature verified by |
| 75 | + the ssg-mcp cartridge, with Cloudflare tier-1 WAF in front. Documented |
| 76 | + exception — do not generalise to other POST routes." |
| 77 | + |
| 78 | + # ---- Authenticated (credentialed callers) --------------------------- |
| 79 | + - path: "/status" |
| 80 | + verbs: [GET] |
| 81 | + exposure: "authenticated" |
| 82 | + name: "status-get" |
| 83 | + narrative: "Detailed server status exposes internal topology; gate behind |
| 84 | + authentication. (Declared in openapi.yaml; not yet wired in router.ex — |
| 85 | + governed in advance, contract §8.)" |
| 86 | + |
| 87 | + - path: "/menu" |
| 88 | + verbs: [GET] |
| 89 | + exposure: "authenticated" |
| 90 | + name: "menu-get" |
| 91 | + narrative: "Full cartridge catalogue (Teranga menu). Enumerates installed |
| 92 | + capabilities; requires authentication." |
| 93 | + |
| 94 | + - path: "/matrix" |
| 95 | + verbs: [GET] |
| 96 | + exposure: "authenticated" |
| 97 | + name: "matrix-get" |
| 98 | + narrative: "Protocol-domain cartridge matrix. Capability enumeration; |
| 99 | + authenticated. (Declared-not-yet-wired, contract §8.)" |
| 100 | + |
| 101 | + - path: "/cartridges" |
| 102 | + verbs: [GET] |
| 103 | + exposure: "authenticated" |
| 104 | + name: "cartridges-list-get" |
| 105 | + narrative: "Cartridge name list. Capability enumeration; authenticated." |
| 106 | + |
| 107 | + - path: "^/cartridge/[A-Za-z0-9_.-]+$" |
| 108 | + verbs: [GET] |
| 109 | + exposure: "authenticated" |
| 110 | + name: "cartridge-detail-get" |
| 111 | + narrative: "Single cartridge metadata (cartridge.json). Regex matches one |
| 112 | + cartridge name segment; deliberately does NOT match the /invoke, |
| 113 | + /load, /unload, /reload sub-paths (separate rules)." |
| 114 | + |
| 115 | + - path: "^/cartridge/[A-Za-z0-9_.-]+/invoke$" |
| 116 | + verbs: [POST] |
| 117 | + exposure: "authenticated" |
| 118 | + name: "cartridge-invoke-post" |
| 119 | + narrative: "Tool dispatch into a cartridge. Credentialed: BojRest.Router |
| 120 | + check_trust/3 requires internal/authenticated for cartridges whose |
| 121 | + auth.method is not `none`." |
| 122 | + |
| 123 | + - path: "/graphql" |
| 124 | + verbs: [POST] |
| 125 | + exposure: "authenticated" |
| 126 | + name: "graphql-post" |
| 127 | + narrative: "GraphQL query endpoint. Arbitrary query surface; authenticated. |
| 128 | + (Declared-not-yet-wired, contract §8.)" |
| 129 | + |
| 130 | + - path: "^/grpc/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" |
| 131 | + verbs: [POST] |
| 132 | + exposure: "authenticated" |
| 133 | + name: "grpc-method-post" |
| 134 | + narrative: "gRPC method endpoint. CAVEAT: gRPC is HTTP/2; the gateway |
| 135 | + governs the HTTP request line/path only. If gRPC traffic does not |
| 136 | + traverse the gateway's HTTP path it is out of this policy's reach — |
| 137 | + flagged for the Phase C seam test. (Declared-not-yet-wired.)" |
| 138 | + |
| 139 | + - path: "/sse" |
| 140 | + verbs: [GET] |
| 141 | + exposure: "authenticated" |
| 142 | + name: "sse-get" |
| 143 | + narrative: "MCP/SSE long-lived event stream. CAVEAT: streaming/long-poll |
| 144 | + interacts with the proxy timeout and circuit breaker — Phase C/D must |
| 145 | + confirm the gateway does not prematurely break long-lived SSE |
| 146 | + connections. (Declared-not-yet-wired.)" |
| 147 | + |
| 148 | + - path: "/order" |
| 149 | + verbs: [POST] |
| 150 | + exposure: "authenticated" |
| 151 | + name: "order-post" |
| 152 | + narrative: "Submit a cartridge order. State-changing; authenticated. |
| 153 | + (Declared-not-yet-wired, contract §8.)" |
| 154 | + |
| 155 | + - path: "/order-ticket" |
| 156 | + verbs: [POST] |
| 157 | + exposure: "authenticated" |
| 158 | + name: "order-ticket-post" |
| 159 | + narrative: "Submit an order ticket. State-changing; authenticated. |
| 160 | + (Declared-not-yet-wired, contract §8.)" |
| 161 | + |
| 162 | + - path: "/umoja/status" |
| 163 | + verbs: [GET] |
| 164 | + exposure: "authenticated" |
| 165 | + name: "umoja-status-get" |
| 166 | + narrative: "Umoja federation status. Federation topology; authenticated." |
| 167 | + |
| 168 | + - path: "/umoja/transport" |
| 169 | + verbs: [GET] |
| 170 | + exposure: "authenticated" |
| 171 | + name: "umoja-transport-get" |
| 172 | + narrative: "Federation transport mode. Authenticated read." |
| 173 | + |
| 174 | + - path: "/umoja/peers" |
| 175 | + verbs: [GET] |
| 176 | + exposure: "authenticated" |
| 177 | + name: "umoja-peers-get" |
| 178 | + narrative: "List federation peers. Authenticated read. Mutating POST to |
| 179 | + the same path is a separate internal rule below." |
| 180 | + |
| 181 | + - path: "/coprocessor/status" |
| 182 | + verbs: [GET] |
| 183 | + exposure: "authenticated" |
| 184 | + name: "coprocessor-status-get" |
| 185 | + narrative: "Coprocessor dispatch status. Authenticated read." |
| 186 | + |
| 187 | + - path: "/sla/status" |
| 188 | + verbs: [GET] |
| 189 | + exposure: "authenticated" |
| 190 | + name: "sla-status-get" |
| 191 | + narrative: "SLA monitoring status. Authenticated read." |
| 192 | + |
| 193 | + - path: "/community/submissions" |
| 194 | + verbs: [GET] |
| 195 | + exposure: "authenticated" |
| 196 | + name: "community-submissions-get" |
| 197 | + narrative: "List community cartridge submissions. Authenticated read." |
| 198 | + |
| 199 | + - path: "/community/submit" |
| 200 | + verbs: [POST] |
| 201 | + exposure: "authenticated" |
| 202 | + name: "community-submit-post" |
| 203 | + narrative: "Submit a community cartridge. Authenticated write; further |
| 204 | + review/quarantine is a downstream cartridge concern." |
| 205 | + |
| 206 | + # ---- Internal-only (stealth: hidden from lesser trust) -------------- |
| 207 | + - path: "^/cartridge/[A-Za-z0-9_.-]+/load$" |
| 208 | + verbs: [POST] |
| 209 | + exposure: "internal" |
| 210 | + stealth_profile: "default" |
| 211 | + name: "cartridge-load-post" |
| 212 | + narrative: "Mount a cartridge. Lifecycle mutation; internal-only and |
| 213 | + stealthed (404) so the capability is invisible to lesser trust." |
| 214 | + |
| 215 | + - path: "^/cartridge/[A-Za-z0-9_.-]+/unload$" |
| 216 | + verbs: [POST] |
| 217 | + exposure: "internal" |
| 218 | + stealth_profile: "default" |
| 219 | + name: "cartridge-unload-post" |
| 220 | + narrative: "Unmount a cartridge. Lifecycle mutation; internal + stealth." |
| 221 | + |
| 222 | + - path: "^/cartridge/[A-Za-z0-9_.-]+/reload$" |
| 223 | + verbs: [POST] |
| 224 | + exposure: "internal" |
| 225 | + stealth_profile: "default" |
| 226 | + name: "cartridge-reload-post" |
| 227 | + narrative: "Reload (unmount, verify, remount) a cartridge. Lifecycle |
| 228 | + mutation; internal + stealth." |
| 229 | + |
| 230 | + - path: "/umoja/peers" |
| 231 | + verbs: [POST] |
| 232 | + exposure: "internal" |
| 233 | + stealth_profile: "default" |
| 234 | + name: "umoja-peers-post" |
| 235 | + narrative: "Mutate federation peer set. Membership control is internal + |
| 236 | + stealth. (Same path as the authenticated GET above; the gateway keys |
| 237 | + rules by {path, verb}.)" |
| 238 | + |
| 239 | + - path: "/coprocessor/select" |
| 240 | + verbs: [POST] |
| 241 | + exposure: "internal" |
| 242 | + stealth_profile: "default" |
| 243 | + name: "coprocessor-select-post" |
| 244 | + narrative: "Select a coprocessor. Operational dispatch control; internal |
| 245 | + + stealth. (Declared-not-yet-wired, contract §8.)" |
| 246 | + |
| 247 | + - path: "/sdp/status" |
| 248 | + verbs: [GET] |
| 249 | + exposure: "internal" |
| 250 | + stealth_profile: "default" |
| 251 | + name: "sdp-status-get" |
| 252 | + narrative: "Auto-SDP (single-packet-authorisation) status. A recon target |
| 253 | + for an attacker probing the auth perimeter; internal-only and stealthed |
| 254 | + so its existence is not confirmable from outside." |
| 255 | + |
| 256 | +stealth: |
| 257 | + enabled: true |
| 258 | + status_code: 404 |
0 commit comments