Skip to content

Commit 9eba371

Browse files
authored
feat(apl): generic-HTTP authorization (#110)
* feat(http): add HTTP request-line attributes to HttpExtension 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> * feat(apl): carry custom denial response via PluginViolation.details 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> * feat(apl): evaluate the global policy for entity-less HTTP requests 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> * fix(apl): scope denyWith response to its owning scope 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> * style(apl): rustfmt + clippy fixes for denyWith tests Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com> * fix(apl): close fail-open gaps in entity-less HTTP catch-all 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> * docs(apl): document HTTP request-line attrs, response: block, and entity-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> * docs(apl): drop internal design-doc cross-references from comments Remove requirement/unit/spike identifiers (R5/R7/R8/R9/R10/R11/R14/R15/R16/R17/R18, U1/U2/U3, AE1-AE6, spike phase, Praxis transpiler) from doc comments and test descriptions across apl-core, apl-cpex, and the valkey session store. The comments now describe behavior in their own terms rather than pointing at private design docs the public repo doesn't carry. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com> * docs(apl): standardize examples on canonical authentication/authorization form Migrate all human-facing APL examples to one canonical shape: no `apl:` wrapper, with `authentication:` and `authorization:` as parallel sibling blocks (pre_invocation/post_invocation nested under `authorization:`; args/result/pdp/session_store/response as siblings). - Strip the `apl:` wrapper from _index.md, valkey-session-store.md, and the cedar-direct/cel PDP factory doc-comments; fix incidental `global.apl` / "sibling of the `apl:` block" prose. - Wrap the previously-flat `pre_invocation:` examples (README, quickstart, overview, patterns, vision, deployment, tainting, effects, pdp, delegation) under `authorization:`. - Fix pre-existing invalid YAML in patterns.md and effects.md where the `${args.*}` placeholder sat inside a flow mapping; expand to block form. Docs/convention only; all forms remain accepted by the parser. `hugo --minify` builds clean and every edited YAML block parses. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com> * test(apl): cover canonical authentication/authorization form Give the canonical no-`apl:` shape real coverage: - Convert the FFI (crates/cpex-ffi) and Go (go/cpex/apl_test.go) APL fixtures off the `apl: { authorization: {...} }` wrapper to the canonical sibling `authorization:` form. - Add crates/apl-cpex/tests/canonical_authn_authz_e2e.rs: a self-contained end-to-end test with a route declaring `authentication:` and `authorization:` as siblings (no `apl:`), asserting the identity block dispatches on identity.resolve and the pre_invocation phase runs on cmf.tool_pre_invoke. All touched suites pass (apl-cpex, apl-core, cpex-core, cpex-ffi, go). Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com> * chore(release): bump workspace version to 0.2.1 Bump `[workspace.package] version` and the internal path-dep pins to 0.2.1 (members inherit via `version.workspace`), refresh Cargo.lock, and cut the CHANGELOG `[Unreleased]` section as `[0.2.1]`. 0.2.1 collects the work landed since 0.2.0: HTTP request-line attributes, the custom-denial `response:` block, entity-less HTTP authorization, the PyO3 Python bindings, the authz/authn config-key rename, and the canonical docs config shape. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com> * style(apl): rustfmt the canonical authn/authz e2e test Reformat the adapter construction in the new test to satisfy `cargo fmt --all --check` (the CI Lint job). No behavior change. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com> --------- Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
1 parent 8b18994 commit 9eba371

41 files changed

Lines changed: 1395 additions & 233 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
1313
> - **Fixed**: for any bug fixes.
1414
> - **Security**: in case of vulnerabilities.
1515
16-
## [Unreleased]
16+
## [0.2.1] - 2026-07-14
17+
18+
### Added
19+
20+
- **HTTP request-line attributes.** `HttpExtension` now carries optional `method` / `path` / `host` / `scheme`, surfaced in the APL attribute bag as `http.method` / `http.path` / `http.host` / `http.scheme` so CEL/APL predicates can reason over the HTTP request line. They ride the existing `read_headers` capability (the `http` extension slot is gated as a whole). `http.host` must be populated from a validated request authority (e.g. HTTP/2 `:authority`), never a raw client `Host` header, so host-based policy cannot be spoofed.
21+
- **Custom denial response (`response:` block).** A route — or `global` — may declare a custom HTTP `status` / `body` / `headers` for its denials via a `response:` block (a sibling of `authorization:`). On a deny, these are carried on `PluginViolation.details` (`http.status` / `http.body` / `http.headers`) for the host to render; absent, the host default is unchanged. No new APL grammar and no new `PluginViolation` fields. It is scope-local: a `global` response is not inherited by entity routes, and the block warns (inert) at `defaults` / policy-bundle scope.
22+
- **Entity-less HTTP authorization.** The catch-all `global` policy now authorizes generic (non-MCP/A2A) HTTP requests that carry no entity, via new reserved coordinates (`http` / `*`) and the `cmf.http_request` hook. A host fires `cmf.http_request` with those coordinates; the global `authorization` (or `args`) block is evaluated with `read_headers` granted, and a global `response:` decorates the denial. Fail-closed session-store denials carry the response too.
23+
- **Python bindings (PyO3).** Native `cpex` Python package wrapping the cpex-core `PluginManager`, built with maturin/PyO3. ([#70](https://github.com/contextforge-org/cpex/pull/70))
1724

1825
### Changed
1926

@@ -24,6 +31,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
2431

2532
The two authorization phases may be written either nested under an `authorization:` block or flat directly on the section; the forms are equivalent. The field-pipeline keys `args:` / `result:` are unchanged (they stay aligned with the `args.*` / `result.*` attribute namespaces that predicates and interpolation read). Internal APL IR is unchanged. (#105)
2633

34+
- **Canonical APL config shape in docs.** All documentation, the README, and the bundled examples now use one canonical shape — no `apl:` wrapper, with `authentication:` and `authorization:` as sibling blocks (`pre_invocation:` / `post_invocation:` nested under `authorization:`; `args:` / `result:` / `pdp:` / `session_store:` / `response:` as siblings). Both the `apl:` wrapper and the wrapper-free form remain accepted by the parser; this only standardizes the examples authors copy from.
35+
2736
## [0.2.0] - 2026-06-26
2837

2938
### Added

Cargo.lock

Lines changed: 18 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ default-members = [
6262
]
6363

6464
[workspace.package]
65-
version = "0.2.0"
65+
version = "0.2.1"
6666
edition = "2021"
6767
# MSRV — keep in sync with rust-toolchain.toml `channel` and clippy.toml `msrv`.
6868
rust-version = "1.96"
@@ -110,21 +110,21 @@ regex = "1"
110110
# time cargo substitutes it for the `path`. `cargo release` keeps these in sync
111111
# with the workspace version. Keys are package names (builtins differ from their
112112
# directory names), paths are workspace-root-relative.
113-
cpex-core = { path = "crates/cpex-core", version = "0.2.0" }
114-
cpex-orchestration = { path = "crates/cpex-orchestration", version = "0.2.0" }
115-
cpex-sdk = { path = "crates/cpex-sdk", version = "0.2.0" }
116-
cpex-builtins = { path = "crates/cpex-builtins", version = "0.2.0", default-features = false }
117-
apl-core = { path = "crates/apl-core", version = "0.2.0" }
118-
apl-cmf = { path = "crates/apl-cmf", version = "0.2.0" }
119-
apl-cpex = { path = "crates/apl-cpex", version = "0.2.0" }
120-
cpex-plugin-pii-scanner = { path = "builtins/plugins/pii-scanner", version = "0.2.0" }
121-
cpex-plugin-audit-logger = { path = "builtins/plugins/audit-logger", version = "0.2.0" }
122-
cpex-plugin-identity-jwt = { path = "builtins/plugins/identity-jwt", version = "0.2.0" }
123-
cpex-plugin-delegator-oauth = { path = "builtins/plugins/delegator-oauth", version = "0.2.0" }
124-
cpex-plugin-delegator-biscuit = { path = "builtins/plugins/delegator-biscuit", version = "0.2.0" }
125-
cpex-pdp-cedar-direct = { path = "builtins/pdps/cedar-direct", version = "0.2.0" }
126-
cpex-pdp-cel = { path = "builtins/pdps/cel", version = "0.2.0" }
127-
cpex-session-valkey = { path = "builtins/session/valkey", version = "0.2.0" }
113+
cpex-core = { path = "crates/cpex-core", version = "0.2.1" }
114+
cpex-orchestration = { path = "crates/cpex-orchestration", version = "0.2.1" }
115+
cpex-sdk = { path = "crates/cpex-sdk", version = "0.2.1" }
116+
cpex-builtins = { path = "crates/cpex-builtins", version = "0.2.1", default-features = false }
117+
apl-core = { path = "crates/apl-core", version = "0.2.1" }
118+
apl-cmf = { path = "crates/apl-cmf", version = "0.2.1" }
119+
apl-cpex = { path = "crates/apl-cpex", version = "0.2.1" }
120+
cpex-plugin-pii-scanner = { path = "builtins/plugins/pii-scanner", version = "0.2.1" }
121+
cpex-plugin-audit-logger = { path = "builtins/plugins/audit-logger", version = "0.2.1" }
122+
cpex-plugin-identity-jwt = { path = "builtins/plugins/identity-jwt", version = "0.2.1" }
123+
cpex-plugin-delegator-oauth = { path = "builtins/plugins/delegator-oauth", version = "0.2.1" }
124+
cpex-plugin-delegator-biscuit = { path = "builtins/plugins/delegator-biscuit", version = "0.2.1" }
125+
cpex-pdp-cedar-direct = { path = "builtins/pdps/cedar-direct", version = "0.2.1" }
126+
cpex-pdp-cel = { path = "builtins/pdps/cel", version = "0.2.1" }
127+
cpex-session-valkey = { path = "builtins/session/valkey", version = "0.2.1" }
128128

129129
# Size-first release profile. The FFI artifact (libcpex_ffi.a) is linked
130130
# statically into host binaries, so its compiled size flows straight into

README.md

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,31 +49,34 @@ One policy defines three distinct enforcement pipelines, one for each entity.
4949
routes:
5050
# HR lookup: gate on role, scope a downstream token, redact by permission, taint the session.
5151
- tool: get_compensation
52-
pre_invocation:
53-
- "require(role.hr)"
54-
- "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])"
55-
- "taint(secret, session)"
56-
- "run(audit-log)"
52+
authorization:
53+
pre_invocation:
54+
- "require(role.hr)"
55+
- "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])"
56+
- "taint(secret, session)"
57+
- "run(audit-log)"
5758
result:
5859
ssn: "str | redact(!perm.view_ssn)"
5960

6061
# Repo search: gate on team, decide with CEL (or Cedar), require the scoped grant.
6162
- tool: search_repos
62-
pre_invocation:
63-
- "require(team.engineering | team.security)"
64-
- cel:
65-
expr: "(role.engineer && args.visibility == 'internal') || role.security"
66-
on_deny: ["deny('engineers read internal only; security reads any', 'cel.policy_denied')"]
67-
- "delegate(github-oauth, target: github-api, permissions: [repo:read:internal])"
68-
- "run(audit-log)"
63+
authorization:
64+
pre_invocation:
65+
- "require(team.engineering | team.security)"
66+
- cel:
67+
expr: "(role.engineer && args.visibility == 'internal') || role.security"
68+
on_deny: ["deny('engineers read internal only; security reads any', 'cel.policy_denied')"]
69+
- "delegate(github-oauth, target: github-api, permissions: [repo:read:internal])"
70+
- "run(audit-log)"
6971

7072
# Outbound email: refuse if the session already touched secret data.
7173
- tool: send_email
72-
pre_invocation:
73-
- "require(perm.email_send)"
74-
- "run(pii-scan)"
75-
- "security.labels contains \"secret\": deny('write-down blocked', 'session_tainted')"
76-
- "run(audit-log)"
74+
authorization:
75+
pre_invocation:
76+
- "require(perm.email_send)"
77+
- "run(pii-scan)"
78+
- "security.labels contains \"secret\": deny('write-down blocked', 'session_tainted')"
79+
- "run(audit-log)"
7780
```
7881
7982
Two examples illustrate the behavior:

builtins/pdps/cedar-direct/src/factory.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,12 @@
99
//
1010
// ```yaml
1111
// global:
12-
// apl:
13-
// pdp:
14-
// - kind: cedar-direct
15-
// dialect: cedar # optional, defaults to PdpDialect::Cedar
16-
// policy_text: | # required (or policy_file)
17-
// @id("owner-override")
18-
// permit(...);
12+
// pdp:
13+
// - kind: cedar-direct
14+
// dialect: cedar # optional, defaults to PdpDialect::Cedar
15+
// policy_text: | # required (or policy_file)
16+
// @id("owner-override")
17+
// permit(...);
1918
// ```
2019
//
2120
// Hosts register an instance of this factory in `AplOptions.pdp_factories`;

builtins/pdps/cel/src/factory.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@
88
//
99
// ```yaml
1010
// global:
11-
// apl:
12-
// pdp:
13-
// - kind: cel
14-
// on_error: deny # optional; deny | allow, default deny
11+
// pdp:
12+
// - kind: cel
13+
// on_error: deny # optional; deny | allow, default deny
1514
// ```
1615
//
1716
// The CEL expression itself lives in each route's `cel: { expr: "..." }`

builtins/session/valkey/src/config.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// Authors: Fred Araujo
55
//
66
// Parses and validates the `global.apl.session_store` block for the
7-
// Valkey backend. Deliberately minimal (R11): a single endpoint, TLS,
7+
// Valkey backend. Deliberately minimal: a single endpoint, TLS,
88
// auth, key prefix, optional sliding TTL, and fail-closed timeout/retry
99
// knobs with committed safe defaults. Sentinel/Cluster fields are NOT
1010
// present — they are out of scope and would be dead config surface.
@@ -52,17 +52,17 @@ pub struct ValkeyConfig {
5252
#[serde(default)]
5353
pub password: Option<String>,
5454

55-
/// Key prefix/namespace for label keys (R9).
55+
/// Key prefix/namespace for label keys.
5656
#[serde(default = "default_key_prefix")]
5757
pub key_prefix: String,
5858

5959
/// Sliding TTL in seconds, refreshed on load and append. `None`
60-
/// (default) means no expiry (R7).
60+
/// (default) means no expiry.
6161
#[serde(default)]
6262
pub ttl_seconds: Option<u64>,
6363

6464
/// Declared maximum session-identity lifetime, used only to emit the
65-
/// TTL-soundness warning (R17) when `ttl_seconds` is shorter.
65+
/// TTL-soundness warning when `ttl_seconds` is shorter.
6666
#[serde(default)]
6767
pub max_session_lifetime_seconds: Option<u64>,
6868

@@ -90,9 +90,9 @@ impl ValkeyConfig {
9090
}
9191

9292
/// Enforce the non-negotiable invariants. TLS is mandatory off
93-
/// localhost (R10); a `tls: true` + plaintext `redis://` scheme is a
93+
/// localhost; a `tls: true` + plaintext `redis://` scheme is a
9494
/// contradiction (would connect in cleartext); the connection URL
95-
/// must build; the TTL-soundness warning (R17) is emitted here.
95+
/// must build; the TTL-soundness warning is emitted here.
9696
///
9797
/// All error text routes the endpoint through [`redact_endpoint`] so
9898
/// embedded credentials never leak into errors or logs.
@@ -152,7 +152,7 @@ impl ValkeyConfig {
152152
ttl_seconds = ttl,
153153
max_session_lifetime_seconds = life,
154154
"valkey session_store TTL is shorter than the declared max session lifetime; \
155-
accumulated taint can silently expire (downgrade-by-waiting) — see R8"
155+
accumulated taint can silently expire (downgrade-by-waiting)"
156156
);
157157
}
158158
}

builtins/session/valkey/src/connection.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// SPDX-License-Identifier: Apache-2.0
44
// Authors: Fred Araujo
55
//
6-
// Internal connection layer (R14): builds and holds the deadpool-redis
6+
// Internal connection layer: builds and holds the deadpool-redis
77
// pool for the Valkey backend. Kept private to this crate — it is NOT a
88
// public reusable API. When a second consumer (the planned OAuth token
99
// cache) is actually scheduled, extract a shared layer then

builtins/session/valkey/src/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ pub enum BuildError {
1717
#[error("invalid valkey session_store config: {0}")]
1818
Config(String),
1919

20-
/// TLS is mandatory for any non-localhost endpoint (R10): session
20+
/// TLS is mandatory for any non-localhost endpoint: session
2121
/// security labels must not transit a network segment in plaintext.
2222
#[error(
2323
"valkey session_store requires TLS for non-localhost endpoint '{0}' \

0 commit comments

Comments
 (0)