Skip to content

Commit 4d196b9

Browse files
hyperpolymathclaude
andcommitted
Phase C: E2E verification + gateway↔BoJ seam tests
Wires the Phase A contract assertion that BoJ's gnosis handler observes the gateway-resolved trust class and request id (not anything the client could have written), and pins the invariant with both an end-to-end seam test and pipeline property tests. * `Proxy.build_backend_headers/1` now appends `X-Trust-Level` from `conn.assigns[:trust_level]` and `X-Request-ID` from `conn.assigns[:request_id]`, overriding any client-supplied value so the SafeTrust attack surface cannot leak past the strip+extract pipeline. The two headers ride last in the list so the `Enum.into(%{})` collapse treats them as authoritative. * `test/e2e_boj_integration_test.exs` drives the full Plug pipeline against a real backend (a Plug.Cowboy mock standing in for the gnosis handler at TCP localhost -- the Phase A staging transport) and reads back every forwarded request from an ETS capture table. Covers the six C1 cases plus request-id propagation and trust-header forgery resistance. * `test/e2e_property_test.exs` proves two pipeline invariants on generated policies: (i) `lookup/3` never returns a rule for a verb not declared in the policy at that path; (ii) monotonicity of denial survives composition with policy lookup -- if denied at trust T, denied at every T' < T. Refs hyperpolymath/standards#91 Closes hyperpolymath/standards#98 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2886d61 commit 4d196b9

3 files changed

Lines changed: 537 additions & 1 deletion

File tree

lib/http_capability_gateway/proxy.ex

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ defmodule HttpCapabilityGateway.Proxy do
2828
- Filters out hop-by-hop headers (Connection, Keep-Alive, etc.)
2929
- Adds X-Forwarded-* headers for provenance
3030
- Preserves Authorization headers
31+
- Sets `X-Trust-Level` from the gateway-resolved trust level
32+
(`conn.assigns[:trust_level]`), overriding any client-supplied value.
33+
This is the BoJ contract (Phase A) -- BoJ's gnosis handler trusts the
34+
header as authoritative because the gateway has already resolved it
35+
(via mTLS in Phase B, or via trusted-proxy header in development).
36+
- Sets `X-Request-ID` from the gateway-resolved request ID
37+
(`conn.assigns[:request_id]`), overriding any client-supplied value
38+
so the trace ID matches the gateway access log.
3139
"""
3240

3341
require Logger
@@ -106,11 +114,19 @@ defmodule HttpCapabilityGateway.Proxy do
106114
end
107115
end
108116

109-
# Build headers for backend request
117+
# Build headers for backend request.
118+
#
119+
# The gateway-resolved trust level and request ID are appended LAST so
120+
# that they override any client-supplied X-Trust-Level / X-Request-ID
121+
# when the header list is collapsed into a map (last-write-wins). This
122+
# is the Phase A contract invariant: the trust class the backend sees
123+
# is the value the gateway resolved, never a header the client could
124+
# have forged.
110125
defp build_backend_headers(conn) do
111126
conn.req_headers
112127
|> filter_hop_by_hop_headers()
113128
|> add_forwarded_headers(conn)
129+
|> add_gateway_resolved_headers(conn)
114130
|> Enum.into(%{})
115131
end
116132

@@ -134,6 +150,39 @@ defmodule HttpCapabilityGateway.Proxy do
134150
]
135151
end
136152

153+
# Append the gateway-resolved trust level and request ID. These keys
154+
# appear LAST so the Enum.into(%{}) at the end of build_backend_headers
155+
# treats them as authoritative -- any client-supplied X-Trust-Level
156+
# (the SafeTrust attack surface) is shadowed by the value the gateway
157+
# actually resolved during the strip+extract pipeline.
158+
defp add_gateway_resolved_headers(headers, conn) do
159+
trust_value =
160+
conn.assigns
161+
|> Map.get(:trust_level, :untrusted)
162+
|> trust_to_string()
163+
164+
request_id =
165+
conn.assigns
166+
|> Map.get(:request_id, "")
167+
|> to_string()
168+
169+
headers ++
170+
[
171+
{"x-trust-level", trust_value},
172+
{"x-request-id", request_id}
173+
]
174+
end
175+
176+
# Normalise a trust level into the string contract BoJ's gnosis handler
177+
# expects. Accepts the SafeTrust atoms (`:untrusted`, `:authenticated`,
178+
# `:internal`) and pass-through binary values for defensive forward-compat;
179+
# anything else falls through as "untrusted".
180+
defp trust_to_string(level) when level in [:untrusted, :authenticated, :internal],
181+
do: Atom.to_string(level)
182+
183+
defp trust_to_string(level) when is_binary(level), do: level
184+
defp trust_to_string(_), do: "untrusted"
185+
137186
# Make HTTP request to backend using Req
138187
defp make_backend_request(method, url, headers, body) do
139188
method_atom = method |> String.downcase() |> String.to_existing_atom()

test/e2e_boj_integration_test.exs

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
defmodule HttpCapabilityGateway.E2EBojIntegrationTest do
3+
@moduledoc """
4+
Phase C (`hyperpolymath/standards#98`) -- end-to-end verification of the
5+
gateway ↔ BoJ unified-zig-api gnosis-handler seam.
6+
7+
These tests drive the full Plug pipeline (security headers → strip
8+
untrusted headers → extract trust → rate-limit → policy lookup →
9+
SafeTrust → proxy) and assert the contract that BoJ's gnosis handler
10+
observes on the wire. A real backend listening on TCP localhost stands
11+
in for the gnosis handler (the staging transport choice per the Phase A
12+
contract); it captures every forwarded request into an ETS table so
13+
each test can read back exactly what BoJ would have received.
14+
15+
Contract under test: `docs/integration/http-capability-gateway-boj-contract.md`
16+
in the boj-server repo (ADR 0004). The seam invariants asserted here:
17+
18+
* `X-Trust-Level` is the gateway-resolved trust class, NOT what the
19+
client supplied. The backend trusts it as authoritative because the
20+
gateway has already stripped any client-supplied value (from
21+
untrusted sources) and re-set it from the compiled trust value.
22+
* `X-Request-ID` is the gateway's resolved request id (auto-generated
23+
if absent) so backend logs join cleanly to gateway access logs.
24+
* `X-Gateway: http-capability-gateway` and the `X-Forwarded-*` tags
25+
are set per the Phase A contract.
26+
* Denied requests (verb not in policy, trust < exposure, default
27+
unmatched path) never reach the backend.
28+
29+
The live mTLS handshake (`verify: :verify_peer`) is a transport-level
30+
guarantee proven separately by Phase B (`test/mtls_test.exs`). These
31+
tests run over the plaintext Plug pipeline with trust supplied via the
32+
trusted-proxy header path, which is the development mode the same
33+
invariant must hold for.
34+
"""
35+
36+
use ExUnit.Case, async: false
37+
38+
import Plug.Conn
39+
import Plug.Test
40+
41+
alias HttpCapabilityGateway.{Gateway, PolicyCompiler}
42+
43+
@backend_port 19_876
44+
@backend_url "http://localhost:#{@backend_port}"
45+
@capture_table :e2e_boj_seam_capture
46+
47+
# Stand-in for BoJ's unified Zig API gnosis handler. Captures every
48+
# request it receives -- method, path, headers, body -- into an ETS
49+
# table so the test assertions can read back what BoJ would have seen.
50+
defmodule MockBoj do
51+
use Plug.Router
52+
plug(:match)
53+
plug(:dispatch)
54+
55+
match _ do
56+
{:ok, body, conn} = Plug.Conn.read_body(conn)
57+
58+
:ets.insert(
59+
:e2e_boj_seam_capture,
60+
{:erlang.unique_integer([:monotonic]),
61+
%{
62+
method: conn.method,
63+
path: conn.request_path,
64+
headers: Map.new(conn.req_headers),
65+
body: body
66+
}}
67+
)
68+
69+
conn
70+
|> put_resp_content_type("application/json")
71+
|> send_resp(200, Jason.encode!(%{ok: true, gnosis_received: conn.request_path}))
72+
end
73+
end
74+
75+
setup_all do
76+
# The ETS capture table is owned by a long-lived process so it
77+
# outlives any single test process. Tests reset it between runs
78+
# in the per-test `setup` block.
79+
owner =
80+
spawn(fn ->
81+
receive do
82+
:stop -> :ok
83+
end
84+
end)
85+
86+
:ets.new(@capture_table, [
87+
:ordered_set,
88+
:public,
89+
:named_table,
90+
{:heir, owner, :no_state}
91+
])
92+
93+
{:ok, _pid} = Plug.Cowboy.http(MockBoj, [], port: @backend_port)
94+
95+
HttpCapabilityGateway.RateLimiter.init([])
96+
HttpCapabilityGateway.K9Contract.init()
97+
98+
Application.put_env(:http_capability_gateway, :backend_url, @backend_url)
99+
100+
on_exit(fn ->
101+
Plug.Cowboy.shutdown(MockBoj.HTTP)
102+
send(owner, :stop)
103+
end)
104+
105+
:ok
106+
end
107+
108+
setup do
109+
:ets.delete_all_objects(@capture_table)
110+
111+
policy = %{
112+
"dsl_version" => "1",
113+
"service" => %{"name" => "phase-c-seam-test"},
114+
"governance" => %{
115+
# GET only as the global verb so verb-not-declared (e.g. PUT) is
116+
# naturally a default-deny case below.
117+
"global_verbs" => ["GET"],
118+
"routes" => [
119+
%{
120+
"path" => "/health",
121+
"verbs" => ["GET"],
122+
"exposure" => "public",
123+
"narrative" => "Health probe; always public."
124+
},
125+
%{
126+
"path" => "/cartridges",
127+
"verbs" => ["GET", "POST"],
128+
"exposure" => "authenticated",
129+
"narrative" => "Cartridge discovery requires authentication."
130+
},
131+
%{
132+
"path" => "/admin",
133+
"verbs" => ["GET", "DELETE"],
134+
"exposure" => "internal",
135+
"narrative" => "Admin surface restricted to internal trust."
136+
}
137+
]
138+
},
139+
"stealth" => %{"enabled" => true, "status_code" => 404}
140+
}
141+
142+
{:ok, table} = PolicyCompiler.compile(policy, delete_old: false)
143+
Application.put_env(:http_capability_gateway, :policy_table, table)
144+
145+
Application.put_env(:http_capability_gateway, :stealth_profiles, %{
146+
"default" => %{
147+
"untrusted" => 404,
148+
"authenticated" => 403
149+
}
150+
})
151+
152+
Application.put_env(:http_capability_gateway, :strip_trust_header, true)
153+
Application.put_env(:http_capability_gateway, :trusted_proxies, ["127.0.0.1", "::1"])
154+
155+
{:ok, table: table}
156+
end
157+
158+
defp captured do
159+
@capture_table
160+
|> :ets.tab2list()
161+
|> Enum.sort_by(fn {ord, _} -> ord end)
162+
|> Enum.map(fn {_, req} -> req end)
163+
end
164+
165+
# ── Phase C / C1: case-by-case seam matrix ────────────────────────
166+
167+
describe "public rule, untrusted client" do
168+
test "request reaches BoJ with the gateway-resolved trust forwarded" do
169+
conn =
170+
conn(:get, "/health")
171+
|> Gateway.call([])
172+
173+
assert conn.status == 200
174+
assert [req] = captured()
175+
assert req.method == "GET"
176+
assert req.path == "/health"
177+
assert req.headers["x-trust-level"] == "untrusted"
178+
assert req.headers["x-gateway"] == "http-capability-gateway"
179+
assert is_binary(req.headers["x-forwarded-for"])
180+
assert is_binary(req.headers["x-forwarded-proto"])
181+
assert is_binary(req.headers["x-forwarded-host"])
182+
assert is_binary(req.headers["x-request-id"]) and req.headers["x-request-id"] != ""
183+
end
184+
end
185+
186+
describe "authenticated rule, trusted-proxy header path" do
187+
test "X-Trust-Level: authenticated is honoured and forwarded onward" do
188+
# Plug.Test conns default to remote_ip {127, 0, 0, 1}, which sits in
189+
# :trusted_proxies, so the supplied header survives strip_untrusted_headers/2
190+
# and the gateway resolves trust as :authenticated.
191+
conn =
192+
conn(:get, "/cartridges")
193+
|> put_req_header("x-trust-level", "authenticated")
194+
|> Gateway.call([])
195+
196+
assert conn.status == 200
197+
assert [req] = captured()
198+
# The gateway forwards its OWN resolved value (here :authenticated)
199+
# regardless of what the client wrote -- they happen to agree
200+
# because the source IP was trusted.
201+
assert req.headers["x-trust-level"] == "authenticated"
202+
end
203+
204+
test "an internal trust on a trusted source is forwarded as internal" do
205+
conn =
206+
conn(:get, "/admin")
207+
|> put_req_header("x-trust-level", "internal")
208+
|> Gateway.call([])
209+
210+
assert conn.status == 200
211+
assert [req] = captured()
212+
assert req.headers["x-trust-level"] == "internal"
213+
end
214+
end
215+
216+
describe "authenticated rule, untrusted client" do
217+
test "request is denied with the stealth response and never forwarded" do
218+
conn = conn(:get, "/cartridges") |> Gateway.call([])
219+
220+
# untrusted < authenticated -> deny. Stealth profile maps untrusted -> 404.
221+
assert conn.status == 404
222+
assert captured() == []
223+
end
224+
end
225+
226+
describe "internal rule, non-internal client" do
227+
test "authenticated client on an internal route is denied without forwarding" do
228+
conn =
229+
conn(:delete, "/admin")
230+
|> put_req_header("x-trust-level", "authenticated")
231+
|> Gateway.call([])
232+
233+
# authenticated < internal -> deny. Stealth profile maps authenticated -> 403.
234+
assert conn.status in [403, 404]
235+
assert captured() == []
236+
end
237+
end
238+
239+
describe "verb not declared for the path" do
240+
test "PUT to /cartridges (verbs are [GET,POST]) is denied without forwarding" do
241+
conn =
242+
conn(:put, "/cartridges")
243+
|> put_req_header("x-trust-level", "authenticated")
244+
|> Gateway.call([])
245+
246+
assert conn.status in [403, 404]
247+
assert captured() == []
248+
end
249+
end
250+
251+
describe "path not in policy" do
252+
test "non-global verb on an undeclared path is denied without forwarding" do
253+
conn =
254+
conn(:delete, "/totally/undeclared/path")
255+
|> put_req_header("x-trust-level", "internal")
256+
|> Gateway.call([])
257+
258+
# No global DELETE, no /totally/undeclared rule -> default-deny.
259+
assert conn.status in [403, 404]
260+
assert captured() == []
261+
end
262+
end
263+
264+
# ── Phase C / C1: request-id propagation ─────────────────────────
265+
266+
describe "request-id propagation across the seam" do
267+
test "a client-supplied X-Request-ID flows through to BoJ" do
268+
conn =
269+
conn(:get, "/health")
270+
|> put_req_header("x-request-id", "boj-seam-trace-001")
271+
|> Gateway.call([])
272+
273+
assert conn.status == 200
274+
assert [req] = captured()
275+
assert req.headers["x-request-id"] == "boj-seam-trace-001"
276+
end
277+
278+
test "an absent X-Request-ID is auto-generated and still forwarded" do
279+
conn = conn(:get, "/health") |> Gateway.call([])
280+
281+
assert conn.status == 200
282+
assert [req] = captured()
283+
# `get_request_id/1` in the gateway emits a 32-char lowercase hex id
284+
# when none is supplied; the seam test asserts the contract on the
285+
# observable wire, not on the implementation, so we check shape.
286+
assert is_binary(req.headers["x-request-id"])
287+
assert byte_size(req.headers["x-request-id"]) > 0
288+
assert req.headers["x-request-id"] == conn.assigns[:request_id]
289+
end
290+
end
291+
292+
# ── Phase C / C1: gateway is authoritative for X-Trust-Level ─────
293+
294+
describe "trust header forgery resistance" do
295+
test "an untrusted client cannot smuggle X-Trust-Level: internal through to BoJ" do
296+
# Plug.Test conns default to 127.0.0.1, which is in :trusted_proxies.
297+
# Tighten the allowlist for this test so the test conn looks
298+
# 'untrusted' from the gateway's perspective and any supplied
299+
# X-Trust-Level is stripped before extract_trust resolves it.
300+
Application.put_env(:http_capability_gateway, :trusted_proxies, ["10.255.255.255"])
301+
302+
try do
303+
conn =
304+
conn(:get, "/health")
305+
|> put_req_header("x-trust-level", "internal")
306+
|> Gateway.call([])
307+
308+
assert conn.status == 200
309+
assert [req] = captured()
310+
# The smuggled "internal" was stripped; the gateway resolved trust
311+
# from the (now empty) header path and forwarded the authoritative
312+
# "untrusted" value on the BoJ-bound request.
313+
assert req.headers["x-trust-level"] == "untrusted"
314+
after
315+
Application.put_env(:http_capability_gateway, :trusted_proxies, ["127.0.0.1", "::1"])
316+
end
317+
end
318+
end
319+
end

0 commit comments

Comments
 (0)