Skip to content

feat(apl): generic-HTTP authorization#110

Open
araujof wants to merge 7 commits into
devfrom
feat/http_ext_fields
Open

feat(apl): generic-HTTP authorization#110
araujof wants to merge 7 commits into
devfrom
feat/http_ext_fields

Conversation

@araujof

@araujof araujof commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Engine-side additions that let CPEX authorize generic (non-MCP/A2A) HTTP requests. These are consumed by the Praxis policy filter's new experimental enforcement: http mode to run Kuadrant-AuthPolicy-style authorization (see the companion Praxis PR). All changes are additive and non-breaking — existing MCP/entity behavior is unchanged.

Draft: pairs with the Praxis AuthPolicy spike praxis-proxy/praxis#746. Cut a release from this branch before Praxis pins it (Praxis currently consumes it via a temporary path dep).

What's here

  • HTTP request line on HttpExtension. Optional method/path/host/scheme, surfaced in the APL bag as http.method/http.path/http.host/http.scheme. Ride the existing read_headers capability (the http slot is gated as a whole in filter_extensions; a base-tier split would need granular sub-field filtering — deferred). host is documented to come from a validated authority, never a raw client Host.
  • custom denial response via PluginViolation.details. A per-route response: block (transpiled denyWith) is carried on CompiledRoute (additive; most-specific layer wins) and, on Decision::Deny, stashed into the existing details map under http.status / http.body / http.headers. No new fields on PluginViolation, no new APL grammar — reason-only denies are unchanged.
  • evaluate the global policy for entity-less requests. New reserved coordinates ENTITY_HTTP / ENTITY_NAME_GLOBAL and the cmf.http_request hook. The visitor installs a Pre-phase handler bound to the compiled global policy under those coordinates (granted read_headers), so a host can authorize a request that carries no MCP entity. Entity routes still stack global via apply_layer.

Tests

  • apl-cmf: request-line surfacing in the bag.
  • apl-core: DenyResponse apply_layer merge.
  • apl-cpex: response: sub-block parsing; and a new end-to-end global_http_authz test exercising everything together — allow, deny, and custom denyWith for an entity-less HTTP request.

Full apl-core / apl-cpex / cpex-core suites green; clippy + fmt clean.

araujof added 3 commits June 30, 2026 11:27
Add optional method/path/host/scheme to HttpExtension and surface them in
the APL bag as http.method/path/host/scheme. These let CEL/APL policies
reason over the HTTP request line — needed by the Praxis AuthPolicy
transpiler, where Kuadrant predicates over request.method/path/host map to
http.* (Praxis spike Phase B / U1).

The request line rides the existing read_headers capability: the `http`
extension slot is gated as a whole in cpex-core's filter_extensions, so a
base-tier split would require granular http sub-field filtering (deferred).
The host field is documented to be populated from a validated authority
(e.g. HTTP/2 :authority), never a raw client Host header, so host-based
policy cannot be bypassed.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Add a per-route `response:` block (the transpiled form of a Kuadrant
AuthPolicy `denyWith`) that lets a route declare a custom HTTP status,
body, and headers for its denials (Praxis spike Phase B / U2).

- New optional DenyResponse on CompiledRoute (additive; most-specific
  layer wins in apply_layer). Read out-of-band from the route YAML by the
  apl-cpex visitor, like the `policy:` block — cpex-core tolerates the key.
- On Decision::Deny, route_handler stashes status/body/headers into the
  existing PluginViolation.details map under http.status / http.body /
  http.headers. No new fields on PluginViolation and no new APL grammar —
  the violation type stays stable and reason-only denies are unchanged.

A host (e.g. the Praxis policy filter) reads details to render a custom
denial response; absent → host default behavior.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Make the catch-all `global` policy enforce on generic (non-MCP/A2A) HTTP
requests, which carry no entity (Praxis spike Phase B / U3).

- New reserved coordinates: ENTITY_HTTP ("http") / ENTITY_NAME_GLOBAL ("*")
  and the HOOK_CMF_HTTP_REQUEST ("cmf.http_request") hook.
- The visitor installs a Pre-phase AplRouteHandler bound to the compiled
  global policy under those coordinates, granted read_headers so the
  policy can read the request line/headers. Entity routes still stack
  `global` via apply_layer; this adds the entity-less evaluation path.
- A global-scope `response:` block (transpiled denyWith) is carried onto
  the global handler and surfaced on deny via PluginViolation.details (U2).

A host fires invoke_named::<CmfHook>("cmf.http_request", ...) with
meta.entity_type/name set to the reserved coordinates. End-to-end tests
cover allow, deny, and custom-denyWith — exercising U1 + U2 + U3 together.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@araujof araujof requested a review from terylt July 1, 2026 00:57
@araujof araujof added enhancement New feature or request framework Rust labels Jul 1, 2026
@araujof araujof added this to CPEX Jul 1, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in CPEX Jul 1, 2026
@araujof araujof added this to the 0.2.1 milestone Jul 1, 2026
@araujof araujof marked this pull request as ready for review July 1, 2026 00:57
@araujof araujof requested a review from jonpspri as a code owner July 1, 2026 00:57
@araujof araujof changed the title feat(apl): generic-HTTP authorization (request line, denyWith, global eval) feat(apl): generic-HTTP authorization Jul 1, 2026
@terylt

terylt commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Nice — the feature is well-structured and the additive claims hold up. I traced the capability-namespace / filter_extensions gating and it's safe: the whole http slot rides read_headers and the new exact-key constants (http.method etc., no trailing .) don't over-grant. A few things I'd want addressed before merge, plus one design question.

Would block on

1. The deny-response decoration isn't gated to actual denials — it can corrupt the HIL elicitation protocol.
In route_handler.rs, the new block

if let (Some(v), Some(resp)) = (violation.as_mut(), self.route.response.as_ref()) { ... }

runs after the pending-elicitation assignment (violation = Some(pending_violation(p)), -32120) and the approved-peek assignment (approved_peek_violation, -32121), and decorates whatever violation is currently set — not just a genuine Decision::Deny. So a route that suspends on an approval/elicitation step gets its -32120 "retry" / -32121 "confirm-to-apply" signal stamped with http.status/http.body/http.headers. A host that maps http.status onto the wire then renders a 403 instead of the retry signal, and the approve/retry loop can't resolve. Combined with #2 (a global response: propagates onto every route) this is reachable without a per-route response: at all.
Suggest gating the decoration to the Decision::Deny arm (or code == "policy.deny" / rule-source denials) rather than any set violation.

2. A global response: is inherited by every entity route, including MCP tool/llm/prompt/resource.
visit_route applies global_layer first onto an empty effective, and the new apply_layer line if more_specific.response.is_some() { self.response = more_specific.response } copies global's response in. So a global catch-all denyWith (meant for HTTP) ends up stamped onto MCP entity denials too. And once inherited it can only be overridden, never unset — a route that wants the plain JSON-RPC deny shape has no opt-out. Worth deciding whether response should layer for non-HTTP entities at all, or be scoped to the HTTP entity. This is the root that makes #1 reachable.

Gaps worth a look

3. The R18 session.persist_failed denial never picks up the custom response. The decoration block runs before the fail-closed logic, and the session.persist_failed violation is constructed after it — so a route with a configured response: renders custom denials for policy denies but the default shape specifically on persist failures. Inconsistent.

4. response: at default / tag scope is silently dropped. response_subblock is only read in visit_global and visit_route; visit_default and visit_policy_bundle never read it, so default_layers[...].response / tag_layers[...].response stay None and never propagate. An operator putting response: under global.defaults.tool or a policies.<tag> bundle gets no effect and no warning (unlike the existing global-only-key lint).

5. A malformed route response: silently falls back to the inherited/global response. On a deserialize error response_subblock warns and returns None, so the route keeps whatever global layered in (see #2). The operator believes the route serves its custom page; it serves the inherited one. The warn-only path is a reasonable choice, but combined with global inheritance the failure mode is "wrong page," not "default page."

Nits

6. response_subblock(yaml, &route_key) is parsed inside the per-entity loop, but its result depends only on the loop-invariant yaml. For a route matching N entities it re-deserializes (and re-warns) N times — hoist above the loop.

7. The global-handler install block duplicates visit_route's dispatch-state snapshot boilerplate (registry/router/session_store RwLock-read + Arc-wrap) verbatim. A small snapshot_dispatch_state() helper would keep the poison-recovery policy from diverging.

8. The detail keys "http.status"/"http.body"/"http.headers" are bare literals on both the producer (route_handler.rs) and consumer (test, future host renderer) side, while this same PR introduces BAG_HTTP_* constants for the request line. Since details is stringly-typed, promoting these to shared constants would couple the two sides.

Design question (non-blocking)

The cmf.http_request hook takes a full MessagePayload, but the request is entirely in the http extension — the test passes Message::text("hi") as a placeholder. Routing HTTP through a CMF hook makes sense to me (it lets the whole plugin chain — audit, rate-limit, token-exchange — act on HTTP, not just APL), but the vestigial payload reads as odd today. Is the intent for the HTTP request body to eventually map into the Message payload (request line → http ext, body → message/args)? If so, a doc comment noting the payload is a deliberate placeholder pending body mapping would help. If body is out of scope permanently, is an Extensions-only hook contract worth considering instead?

Address review feedback on the generic-HTTP authorization PR:

- Stop apply_layer from propagating `response`, so a `global` catch-all
  denyWith no longer leaks onto inherited entity (tool/llm/prompt/resource)
  denials with no opt-out.
- Decorate only genuine denials via a shared decorate_denial_response
  helper, and apply it at the session load/persist fail-closed sites too
  (previously they rendered the default shape).
- Warn when `response:` sits at default/policy-bundle scope, where it is
  inert, instead of dropping it silently.
- Parse the route `response:` once above the per-entity loop.
- Extract snapshot_dispatch_state to share the registry/router/store
  read between the global and per-route handler installs.
- Promote the http.status/body/headers detail keys to DETAIL_HTTP_*
  constants shared by producer and consumer.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@araujof

araujof commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @terylt — all addressed in 4f03fdb.

  • apply_layer no longer propagates response. It's now scope-local: the entity-less HTTP handler carries the global block, an entity route carries only its own. No more leak onto MCP entity denials, and no more "can't unset". Regression test added.
  • Decoration is now applied per-denial-site via a decorate_denial_response helper (Deny arm + fail-closed sites), never blanket-applied to whatever violation is set. Note: the -32120/-32121 elicitation/peek path you describe isn't in this branch or dev yet, so this is hardening rather than a live bug — but the helper keeps it correct once those land.
  • session.load_failed / session.persist_failed denials now pick up the custom response too.
  • response: at default/policy-bundle scope now warns (mirrors the global-only-key lint) instead of dropping silently.
  • With inheritance gone, a malformed/absent route response: falls back to the host default denial, not an inherited page. Still warn-only.
  • response_subblock hoisted above the per-entity loop.
  • Extracted snapshot_dispatch_state(), shared by both install sites.
  • http.status/body/headers promoted to DETAIL_HTTP_* constants, used on both sides.

Note: Keeping the MessagePayload hook contract — the placeholder is deliberate pending body→Message mapping.

araujof added 3 commits July 4, 2026 20:31
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Gate the catch-all handler install on args OR policy (not policy alone),
so an args-only global.apl still authorizes entity-less HTTP traffic.
Warn when a global response: is configured but no installable policy
exists, including the bare response-only block that hit visit_global's
early return. Accept response: nested under apl: as well as top-level,
with top-level taking precedence (documented as deliberate). Cover the
fail-closed session-store denials and the new paths with tests.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
…ity-less HTTP authz

Add http.method/path/host/scheme to the extensions and read_headers
tables. Document the route/global response: (denyWith) block and the
global-policy path that authorizes generic HTTP requests carrying no
MCP/A2A entity.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request framework Rust

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants