Skip to content

feat(policy): back the policy engine's HTTP transport with the pingora subrequest client #1041

Description

@terylt

Description

With policy-engine on, a Praxis process runs two HTTP stacks. Pingora's connector serves the data plane and the proxy's own subrequests, and the policy engine's outbound calls (JWKS fetch, RFC 8693 token exchange, CIBA backchannel) run on a reqwest client the engine builds for itself. Two connection pools against the same IdP, two TLS trust configurations an operator sets separately, and an egress path Praxis's own policy never sees.

policy#20 removed the engine half of that. PPE now performs no outbound HTTP of its own: praxis_policy_core::http::HttpTransport is the seam, plugins reach it through HostServices::http_request gated by a perform_http capability, and a host installs one implementation with PolicyEngine::set_http_transport. reqwest is gone from that workspace entirely. A hyper-backed default ships behind the non-default http-hyper feature for anyone embedding PPE standalone, and Praxis is expected to build without it and link no hyper client at all.

This issue is the Praxis half: implement HttpTransport over praxis_core::subrequest::SubRequestClient and install it before the engine initializes, so the process has one pool, one trust store, and one egress path. Until it lands, "one HTTP stack in the process" is not actually achieved anywhere.

Prerequisite: a PPE release carrying the seam

The filter links ppe = { version = "0.1.0", package = "praxis-policy", features = ["builtins"] } (Cargo.toml:50), and 0.1.0 is the only version published (2026-08-14). The transport seam merged after that, in policy#32 on 2026-08-24, and the policy workspace is still on 0.1.0. So PPE has to cut a release before any of this compiles.

What the adapter has to do

The trait is one method:

async fn execute(&self, req: HttpRequest) -> Result<HttpResponse, HttpTransportError>;

URL to peer resolution is the adapter's job. SubRequestClient::execute takes an &HttpPeer, not a URL (core/src/subrequest/client.rs:441). Praxis has both halves already: connectivity::peer::resolve_address (core/src/connectivity/peer.rs:121) and derive_sni (:319). Getting SNI wrong here is a security bug rather than a cosmetic one, since a token endpoint reached with the wrong name is a token endpoint whose certificate was never really checked.

Error mapping is where the correctness lives. SubRequestError (core/src/subrequest/types.rs:129) mostly lines up: InvalidRequest, Connect, Io and ResponseTooLarge map across directly, DeadlineExceeded becomes Timeout, and StreamIdleTimeout becomes Io because the request was delivered and the response stalled part way. The two with no counterpart are AdmissionTimeout and CircuitOpen, and both must map to HttpTransportError::Rejected.

The reason is HttpTransportError::may_have_reached_peer. PPE's callers branch on it rather than on the variant: Rejected and Connect mean nothing was sent, Timeout and Io mean the outcome is unknown. delegator-oauth turns the first into delegation.egress_denied and the second into delegation.idp_timeout, which is the difference between recording a mint as rejected and recording it as unknown and reconciling. An AdmissionTimeout mapped to Timeout would claim the IdP may have issued a credential when the request never left the process.

No retries in the transport. PPE owns the retry decision per call site through RetryPolicy, because whether a repeat is safe depends on what the request does: a JWKS GET takes RetryPolicy::idempotent, a token mint and a CIBA dispatch take undelivered_only, where repeating a timed-out call could mint a second credential or prompt a human twice. SubRequestClient does not retry today, which is what the seam needs; the thing to preserve is that it stays that way. Resending on a fresh connection when a pooled one turned out to be dead before any bytes were written is not an application retry and is fine.

Nothing eager at construction. PolicyFilter::new drives initialize() on a current-thread runtime built on a dedicated OS thread, which is joined and dropped before new returns (filter/src/builtins/http/security/policy/filter.rs:182). Any connection created while building the transport is bound to a reactor that is gone before the first request arrives. Build lazily on first use. This is the same hazard that produced policy#29.

The wiring problem

set_http_transport has to be called before initialize(), since that is when identity-jwt fetches JWKS. initialize() runs inside PolicyFilter::new (filter.rs:153 builds the engine, :204 drives init), which runs inside FilterPipeline::build_with_chains. The shared SubRequestClient only reaches the pipeline afterwards, in configure_pipeline (server/src/pipelines.rs:76, landing at :120), and the filter factory signature (PolicyFilter::from_config, filter/src/registry.rs:264) never sees it.

There is already a precedent for exactly this shape. host_plugins.rs holds a process-global registry the host populates before starting the server, which PolicyFilter::new reads at construction, for the same reason: the filter builds its own PolicyEngine and nothing else can reach it. A transport holder alongside it is the smaller change. Threading the client through a registry-carried factory is the alternative, and register_http_with_registry (filter/src/registry.rs:305) already exists for factories needing more than their own config.

What matters for this issue is only that the adapter wraps the connector the rest of the proxy uses (server/src/server.rs:190). An adapter that constructs its own SubRequestConnector is a second pool with extra steps and does not close this issue.

Hot reload. PolicyFilter::new runs once per filter instance and again on every reload, building a fresh PolicyEngine each time. set_http_transport is set-once per engine, so each new engine needs the call, and the same Arc<dyn HttpTransport> has to be handed over rather than a freshly built one. Rebuilding it per reload turns a config reload into a slow pool leak.

Decisions this issue owns

1. Whether policy calls go through the circuit breaker. Per-peer circuit breaking is opt-in on the connector (runtime.subrequest_circuit_breaker). Sharing it with the data plane means data-plane failures to a host can open the circuit for a token exchange to that same host, and the reverse. On JWKS the blast radius is smaller than it looks, because a failed refresh leaves the previous keys in place and the next verify tries again, but a boot fetch behind an open circuit yields no keys at all. Decide whether the adapter uses the breaker, bypasses it, or gets a registry keyed separately from proxy traffic.

2. Whether CircuitOpen keeps its own deny code. Mapped to Rejected it becomes delegation.egress_denied / elicitation.egress_denied, alongside SSRF refusals and egress policy. That is the right family, and an operator seeing it still has to work out which of the three it was. A distinct code is a PPE-side change, so decide here and file there if the answer is yes.

3. Whether the adapter refuses private destinations. jwks_url and the token endpoints are URLs from policy config that the gateway fetches on an operator's behalf, and nothing checks where they point. Praxis has the range table (connectivity::is_private_ip) and the precedent for the escape hatch (allow_private_endpoints, allow_private_health_checks, core/src/config/insecure_options.rs:156). The check has to sit on the address dialled rather than on the URL, or DNS rebinding walks straight through it. Note that plenty of real deployments run Keycloak on a private address, so on-by-default is a breaking default and belongs in the changelog as one. PPE has the same open question for its bundled hyper transport; the two answers do not have to match, but both should be deliberate.

4. The response ceiling interaction. execute clamps each call to max_response_bytes.min(self.max_response_bytes), and the server derives the client-wide ceiling from body_limits.max_response_bytes (server/src/server.rs:203). PPE asks for 1 MiB per request. A deployment that set a tight proxy-wide response limit would clamp a JWKS fetch to it and get ResponseTooLarge on a document that is fine, which surfaces as an identity failure rather than as a limit. Decide whether policy calls get their own ceiling.

The capability change that rides along

perform_http is a breaking config change on the PPE bump, independent of this adapter: a plugin using jwks_url, an OAuth delegator, or a CIBA approver must declare it or the engine refuses to start, naming the plugin and the capability.

In this repo that is two files, both of which already carry a capabilities: block on the delegator and neither of which lists it:

  • filter/tests/corpus/demo-cedar.yaml
  • filter/tests/corpus/demo-cel.yaml

The integration fixtures (tests/integration/fixtures/policy.yaml, http-policy.yaml) use inline HS256 secrets and fetch nothing, so they are unaffected. Demo policies living outside this repo are not, and the upgrade note should say so.

Acceptance Criteria

  • An HttpTransport implementation backed by SubRequestClient, installed with set_http_transport before initialize() runs, including on every hot reload
  • It wraps the same SubRequestConnector the rest of the proxy uses, and a test shows no second connector is constructed for policy, on first build or on reload
  • URL to HttpPeer resolution handles scheme, port defaulting and SNI, with a test that a request to https://host/path carries host as SNI
  • AdmissionTimeout and CircuitOpen map to Rejected, with a test asserting may_have_reached_peer() is false for both
  • DeadlineExceeded maps to Timeout and StreamIdleTimeout to Io, both reporting may_have_reached_peer() as true
  • The transport performs no application-level retries, and a test asserts a failing POST produces exactly one attempt at the client
  • Nothing connects at construction: a test builds the transport on one runtime, drops that runtime, and performs a request on another
  • The filter builds without PPE's http-hyper feature, and cargo tree shows no hyper client pulled in on the engine's account
  • filter/tests/corpus/demo-cedar.yaml and demo-cel.yaml declare perform_http, and the policy integration tests pass unchanged otherwise
  • Decisions 1 through 4 are recorded, with any breaking default in the changelog
  • Docs for the policy filter say outbound policy calls now use the proxy's connector, so pool sizing, response limits and circuit breaker settings apply to them

What this does not do

It does not give Praxis a general egress control. Even with decision 3 answered yes, the guard stops the gateway reaching internal hosts; it does nothing about an endpoint on an attacker's public one. For a JWKS URL that is acceptable, since the response is parsed as a key set and never returned, but it should not be described as more than it is.

It also does not change the plugin config surface. jwks_url, the token endpoint settings and the CIBA settings all stay as they are; what changes is which client carries the bytes, and the one capability line above.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions