You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
Description
With
policy-engineon, 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::HttpTransportis the seam, plugins reach it throughHostServices::http_requestgated by aperform_httpcapability, and a host installs one implementation withPolicyEngine::set_http_transport.reqwestis gone from that workspace entirely. A hyper-backed default ships behind the non-defaulthttp-hyperfeature 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
HttpTransportoverpraxis_core::subrequest::SubRequestClientand 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:
URL to peer resolution is the adapter's job.
SubRequestClient::executetakes 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) andderive_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,IoandResponseTooLargemap across directly,DeadlineExceededbecomesTimeout, andStreamIdleTimeoutbecomesIobecause the request was delivered and the response stalled part way. The two with no counterpart areAdmissionTimeoutandCircuitOpen, and both must map toHttpTransportError::Rejected.The reason is
HttpTransportError::may_have_reached_peer. PPE's callers branch on it rather than on the variant:RejectedandConnectmean nothing was sent,TimeoutandIomean the outcome is unknown.delegator-oauthturns the first intodelegation.egress_deniedand the second intodelegation.idp_timeout, which is the difference between recording a mint as rejected and recording it as unknown and reconciling. AnAdmissionTimeoutmapped toTimeoutwould 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 JWKSGETtakesRetryPolicy::idempotent, a token mint and a CIBA dispatch takeundelivered_only, where repeating a timed-out call could mint a second credential or prompt a human twice.SubRequestClientdoes 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::newdrivesinitialize()on a current-thread runtime built on a dedicated OS thread, which is joined and dropped beforenewreturns (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_transporthas to be called beforeinitialize(), since that is when identity-jwt fetches JWKS.initialize()runs insidePolicyFilter::new(filter.rs:153builds the engine,:204drives init), which runs insideFilterPipeline::build_with_chains. The sharedSubRequestClientonly reaches the pipeline afterwards, inconfigure_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.rsholds a process-global registry the host populates before starting the server, whichPolicyFilter::newreads at construction, for the same reason: the filter builds its ownPolicyEngineand 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, andregister_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 ownSubRequestConnectoris a second pool with extra steps and does not close this issue.Hot reload.
PolicyFilter::newruns once per filter instance and again on every reload, building a freshPolicyEngineeach time.set_http_transportis set-once per engine, so each new engine needs the call, and the sameArc<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
CircuitOpenkeeps its own deny code. Mapped toRejectedit becomesdelegation.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_urland 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.
executeclamps each call tomax_response_bytes.min(self.max_response_bytes), and the server derives the client-wide ceiling frombody_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 getResponseTooLargeon 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_httpis a breaking config change on the PPE bump, independent of this adapter: a plugin usingjwks_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.yamlfilter/tests/corpus/demo-cel.yamlThe 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
HttpTransportimplementation backed bySubRequestClient, installed withset_http_transportbeforeinitialize()runs, including on every hot reloadSubRequestConnectorthe rest of the proxy uses, and a test shows no second connector is constructed for policy, on first build or on reloadHttpPeerresolution handles scheme, port defaulting and SNI, with a test that a request tohttps://host/pathcarrieshostas SNIAdmissionTimeoutandCircuitOpenmap toRejected, with a test assertingmay_have_reached_peer()is false for bothDeadlineExceededmaps toTimeoutandStreamIdleTimeouttoIo, both reportingmay_have_reached_peer()as truePOSTproduces exactly one attempt at the clienthttp-hyperfeature, andcargo treeshows no hyper client pulled in on the engine's accountfilter/tests/corpus/demo-cedar.yamlanddemo-cel.yamldeclareperform_http, and the policy integration tests pass unchanged otherwisepolicyfilter say outbound policy calls now use the proxy's connector, so pool sizing, response limits and circuit breaker settings apply to themWhat 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.