Summary
Add a builtin that lets a policy issue a sub-request to an external HTTP service during request processing and use the response in the authorization decision — AuthPolicy's "generic HTTP" external metadata, adapted to PPE's embedded, capability-gated plugin model.
Concretely: a new plugin kind: metadata/http, invoked from authorization.pre_invocation as a run(name) step, that calls a user-defined endpoint (with request attributes mixed into URL / query / headers / body), parses the response, and publishes it into the attribute bag under a new metadata.<name>.* namespace so downstream predicates, PDPs (cel/opa/cedar), and assertions: can read it — typically combined with identity already resolved (subject.*, claim.*).
Motivation — parity gap
This closes the External metadata gap of the AuthPolicy/PPE feature comparison.
It also exercises three cross-cutting items that are currently weaker in PPE than in the AuthPolicy/Authorino and that matter specifically for a network callout:
- Rule/evaluator-level caching with user-defined dynamic cache keys
- Rule/evaluator-level metrics (+ user-defined CEL labels)
- Tracing (OpenTelemetry), data-plane traces
How it differs from what already exists
PPE already does authenticated/cached outbound HTTP in several places; this plugin should reuse that machinery, not reinvent it, and is distinct from each:
identity/jwt JWKS fetching (crates/builtins/src/plugins/identity_jwt/config.rs:248) is the closest technical analog for HTTP-fetch-with-refresh-and-conditional-revalidation, but it is unauthenticated and fetches keys, not per-request metadata.
delegator/oauth (crates/builtins/src/plugins/delegator_oauth/) already implements the OAuth2 client-credentials grant and a result cache — the two pieces this plugin needs for authenticated callouts.
Where it fits in the pipeline
- Hook / phase. Register an
Unphased hook (fires once per request, like identity.resolve and token.delegate — see crates/ppe-core/src/hooks/metadata.rs:106), invoked from an authorization.pre_invocation step with run(name). This places the callout after identity resolution and before the authorization decision, matching AuthPolicy's metadata phase ordering.
- Output namespace. Introduce a new Metadata extension flattened into the bag as
metadata.<instance-name>.* (dotted JSON), gated by a new read_metadata read capability (consistent with the capability catalog in docs/content/extensions.md). Rationale for a dedicated namespace rather than reusing claim.*/custom.*: the source is neither a verified claim nor host-defined custom state; keeping it separate preserves least-privilege gating and keeps audit/provenance honest.
- Merge-back mechanism. Same pattern as the JWT resolver: mutate a cloned payload and return
PluginResult::modify_payload(...) (crates/builtins/src/plugins/identity_jwt/resolver.rs:842,927); add a typed slot + a CMF extractor in crates/ppe-apl-cmf/src/ so the response surfaces in the flat AttributeBag.
Why a new namespace rather than the existing custom.*
PPE already ingests host/filter-chain metadata through the custom.* namespace: the host populates Extensions.custom and extract_custom (crates/ppe-apl-cmf/src/custom.rs:19) bridges it into the bag. That slot is deliberately Unrestricted + Mutable, read_cap: None (crates/ppe-core/src/extensions/filter.rs:124) — ungated precisely because the host authored it from inside the trust boundary. External-HTTP metadata is a different category and should not reuse custom.*:
- Provenance.
custom.* is host-vouched; this data came off the network (SSRF-guarded, authenticated, cacheable, can fail/stale). Routing it through the trusted host channel erases where the attribute came from — bad for audit and for trust reasoning.
- Least privilege. Every other resolved input (subject, claims, client, workload) is its own capability-gated slot, not dumped into
custom.*. Network-fetched enrichment can carry sensitive remote data and should be gated the same way; custom.* is ungated by construction.
- Integrity.
custom is Mutable; a fetched answer should be Immutable once resolved so a later plugin can't rewrite another service's response before the decision.
- Namespacing. Multiple fetchers need isolated
metadata.<name>.* sub-namespaces; the flat host-owned custom map invites key collisions.
- No plugin-write path exists into
custom today (write_cap: None; passed through on clone), so "merge" is not even the cheaper option.
Hence a dedicated Metadata extension → metadata.<name>.*, gated by read_metadata (open sub-question: gated vs ungated — recommended gated, because it crosses a network boundary; PDPs that read it then declare the cap).
Example wiring:
plugins:
- name: geo
kind: metadata/http
hooks: [metadata.fetch]
capabilities: [perform_http, read_subject, read_claims, read_headers, read_request]
config: { ... } # see "The fetching contract" below
routes:
- tool: get_quote
authentication: [corp-jwt]
authorization:
pre_invocation:
- when: "authenticated && exists(subject.id)" # conditional triggering
do: "run(geo)"
- "metadata.geo.country in allowed_regions: allow"
The fetching contract
1. HTTP protocol
Everything maps onto the existing transport request type HttpRequest (crates/ppe-core/src/http.rs:83), so most of this is config → builder:
| Config |
Maps to |
Notes |
method: GET|POST |
HttpRequest.method |
GET and POST to start; others later if asked. |
url |
HttpRequest.url |
Supports attribute interpolation over the bag, e.g. https://geo/v1/users/{subject.id}/loc. |
query: [{name, from}] |
appended to url |
from is a bag key; multi-valued sources (StringSet) repeat the param. |
headers: [{name, from | value}] |
HttpRequest.header() |
static (value) or dynamic (from: <bag key>). |
body: {template, content_type} |
HttpRequest.body |
POST only; template over the bag; form_urlencode helper (http.rs:222) available for form bodies. |
timeout_seconds (default 5) |
HttpRequest.timeout |
DEFAULT_TIMEOUT = 5s (http.rs:54) → closes Configurable timeout. |
connect_timeout_seconds (default 2) |
HttpRequest.connect_timeout |
DEFAULT_CONNECT_TIMEOUT = 2s. |
max_response_bytes (default 1 MiB) |
HttpRequest.max_response_bytes |
DEFAULT_MAX_RESPONSE_BYTES = 1 MiB (http.rs:71); over-limit → HttpTransportError::ResponseTooLarge → closes Configurable maximum response size. |
Attribute interpolation must be constrained to bag keys the plugin's declared read capabilities unlock (so a template cannot exfiltrate claim.* the instance was never granted). Credential-bearing namespaces (raw_credentials.*, http.request_headers.authorization) are the sharp case, and they span two distinct uses — both of which should be the operator's call, not PPE's:
- Placing the inbound token in the request body for the external service to verify — a delegated-credential check. The sub-request still authenticates as PPE itself; the caller's credential is data to be validated, not reused to authenticate. This is exactly what Kubernetes TokenReview does (see "Designed-for reuse").
- Reusing the inbound token as the sub-request's own outbound credential (e.g. copying it into the
Authorization header) — token forwarding / impersonation.
Rather than a hard ban on either, interpolating a credential namespace is off by default and opt-in: it requires both the matching capability (read_inbound_credentials for raw_credentials.*, read_headers for the authorization header) and an explicit config acknowledgement, and the use is audited. This keeps the safe default without PPE deciding these patterns are off-limits — that call is the operator's.
The call itself goes through the host-services seam exactly as every other plugin does — svc.http_request(req, retry_policy) (crates/ppe-core/src/host.rs:178); PPE performs no HTTP of its own. The plugin must declare perform_http or the engine refuses to start and names the missing capability (crates/ppe-core/src/host.rs:344, docs/content/extensions.md "Gating an action rather than a slot").
Retry safety. Use RetryPolicy::idempotent() for GET and undelivered_only() for POST, keyed off HttpTransportError::may_have_reached_peer() (http.rs:440) — same discipline as the Vault provider (crates/builtins/src/secrets/vault/provider.rs:262).
2. TLS configurability
TLS trust is owned by the host-installed transport, not the plugin — this is a deliberate, documented property of the seam (crates/ppe-core/src/http.rs:471): one process, one connection pool, one trust store, one egress policy.
- Plaintext opt-in: a per-plugin
insecure_http: bool (default false, rejects http://), consistent with identity/jwt, delegator/oauth, and vault (crates/builtins/src/plugins/delegator_oauth/config.rs:62).
- CA bundle / client-cert (mTLS) / skip-verify: the bundled
HyperTransport pins webpki-roots with .with_no_client_auth() (crates/ppe/src/http_hyper.rs:254) and exposes none of these as YAML. A deployment that needs a private CA or mTLS to the metadata source does so by injecting its own HttpTransport via PolicyEngine::set_http_transport. A per-plugin TLS config struct (CA path, client cert/key, SNI) is a cross-cutting transport change rather than plugin-local — but it is in scope here, not a follow-up, because the Kubernetes TokenReview reuse (see "Designed-for reuse") cannot connect to the API server without a custom CA bundle and, for the mTLS variant, a client cert.
SSRF. The bundled transport refuses loopback / RFC1918 / link-local (incl. 169.254.169.254) / CGNAT 100.64.0.0/10 both pre-connect on IP literals and at DNS resolution time (crates/ppe-core/src/http_addr.rs:44, crates/ppe/src/http_hyper.rs:350), surfacing as HttpTransportError::Rejected. A metadata source on a private/in-cluster address therefore requires the host to build the transport with with_allow_private_destinations (http_hyper.rs:216) — the same constraint in-cluster Vault already carries. Document it; do not add a YAML hatch.
3. Authentication to the metadata source
A first-class auth: block (tagged enum, #[serde(tag = "kind")], matching the ClientSecretSource / DecodingKeySource convention). There is currently no generic outbound-credential primitive in the codebase — each plugin rolls its own — so this block is net-new and should become the reusable shape.
(a) Shared secret — a static credential attached to the callout, with the secret resolved through the secrets: block by value name, never a raw ref (docs/content/configuration.md, "Secrets and key material"):
auth:
kind: shared_secret
scheme: bearer # bearer | header | basic
header: Authorization # for scheme: header (custom header name)
secret: upstream_api_key # a secrets.values entry
secrets:
providers: { local: { kind: file, base_dir: /etc/ppe } }
values: { upstream_api_key: { provider: local, ref: metadata.key } }
Hold resolved secret material in Zeroizing<String> as delegator/oauth does (crates/builtins/src/plugins/delegator_oauth/delegator.rs:48). For backward compatibility also accept the per-plugin client_secret_source enum (env_var/file/literal).
(b) OAuth2 client-credentials — obtain an access token (RFC 6749 §4.4) and attach it to the callout, reusing delegator/oauth's machinery (GRANT_TYPE_CLIENT_CREDENTIALS, crates/builtins/src/plugins/delegator_oauth/delegator.rs:74; form_urlencode for the body; HTTP Basic client auth):
auth:
kind: oauth2_client_credentials
token_endpoint: "https://idp.example.com/realms/x/protocol/openid-connect/token"
client_id: ppe-metadata
client_secret: metadata_client_secret # secrets.values entry
scopes: [metadata.read]
token_cache: { ttl_seconds: 3600 } # cache the token, not just the response
The fetched token is cached independently of the response cache (§4), with TTL derived from the token response expires_in capped by ttl_seconds.
4. Supported response types
json (default) — parse body, publish the object as metadata.<name>.* (dotted flatten; scalars/arrays map to AttributeValue variants in crates/ppe-apl-core/src/attributes.rs:32). Non-2xx or unparseable body → evaluator error (see fail-closed, below).
text — publish the raw string as metadata.<name> (single scalar).
- Honor
Content-Type for parse selection; reject a body exceeding max_response_bytes before parsing.
- Surface
metadata.<name>.$status and metadata.<name>.$headers.* (operator opt-in) so a policy can branch on the callout's HTTP status without the plugin having to deny on non-2xx itself.
Cross-cutting capabilities
Caching of metadata responses (user-defined, dynamic keys)
This is the most important capability and the one with the sharpest open design question, because PPE deliberately forbids dynamic cache keys today.
What exists. The only result cache is delegator/oauth's DelegatedTokenCache (crates/builtins/src/plugins/delegator_oauth/cache/). Its key derivation (cache/key.rs) is HMAC-SHA256 under a per-process secret and deliberately excludes derived identity (security.subject.id) and refuses to cache when a request-data-bearing value (a rendered template) is in the key (NotCacheable::ResourceTemplate, cache/key.rs:19-45,346). DispatchCache (crates/ppe-apl-runtime/src/dispatch_plan.rs:741) caches compiled route plans, not results, and the valkey session store (crates/ppe-apl-runtime/src/session_store.rs:68) is a monotonic label union, not a general KV cache.
What this needs. Authorino caches metadata keyed on operator-chosen dynamic values (e.g. auth.identity.username). That is exactly what key.rs refuses to do — but the refusal is specific to a credential cache, where serving a cached token to the wrong principal is privilege escalation. A metadata response cache is different in kind: its payload is read-only enrichment, not a credential. Proposed resolution:
cache:
enabled: true
key: [subject.id, claim.tenant] # user-defined lookup key; dynamic bag refs
ttl_seconds: 300
respect_cache_control: true # honor upstream Cache-Control / ETag / 304
max_entries: 10000 # bounded in-process LRU
on_stale_refresh_error: serve_last_good | fail # explicit staleness knob
Design points to settle (see Open questions):
- A cache keyed on
subject.id is sound for enrichment iff (i) the key captures every attribute that varies the response, and (ii) the cached value is only ever used as enrichment feeding predicates, never re-emitted as a credential. This differs intentionally from the credential cache; cite cache/key.rs:19-45 for the contrast and document why metadata relaxes it.
- Reuse the HMAC-keyed
CacheKey construction so cache keys are not guessable/forgeable, but allow derived-identity and request-data inputs that the token cache forbids.
- Conditional revalidation (
ETag + If-None-Match, 304 as hit) already has a working implementation to copy from JWKS fetch (crates/builtins/src/plugins/identity_jwt/config.rs:442; HttpResponse::etag() / cache_max_age() / is_not_modified(), crates/ppe-core/src/http.rs:331-363).
- Backing store. In-process bounded LRU first (like
DelegatedTokenCache and the api-key lookup cache). A distributed/shared cache (valkey) is a follow-up and would need a new store interface — the current SessionStore cannot hold arbitrary values.
Conditional triggering / skipping the callout
No per-plugin "skip unless condition" exists; gating is done by the surrounding APL rule predicate over the attribute bag — which is sufficient and idiomatic:
authorization:
pre_invocation:
- when: "authenticated && !exists(claim.department)" # only call out if we lack it
do: "run(geo)"
Predicate leaves available: Comparison, IsTrue/IsFalse, exists(key), x in set (crates/ppe-apl-core/src/rules.rs:92). This covers AuthPolicy's "conditions" on a metadata source (skip when an attribute is already present, restrict to certain roles/paths, etc.). The plugin itself should additionally short-circuit to a cache hit before issuing the callout, so "skip the sub-request when we already have the answer" holds even inside a single request.
Observability — metrics and auditing
Current state (important). There are no metrics and no OpenTelemetry in the tree — the only observability dependency is tracing = "0.1" (Cargo.toml:102); no prometheus / metrics / opentelemetry crates, and no #[instrument]. Plugins emit tracing:: logs only. So this issue cannot "just add a Prometheus counter"; it should:
- Now: emit structured
tracing events for every callout — outcome (hit/miss/fetched/error), HTTP status, latency_ms, cache (hit/miss/revalidated), and the instance name — so behavior is observable via logs immediately. Emit an audit event per callout (the audit-logger reference plugin, reference/plugins/audit-logger/, is the sink pattern).
- Define the target metric set this plugin will register once a metrics seam exists (counter by outcome, latency histogram, cache hit-ratio), with user-defined CEL labels — matching AuthPolicy's evaluator metrics + custom labels. Flag that the metrics seam itself is a broader cross-cutting prerequisite (the "Rule/evaluator-level metrics = No" row) and should be its own issue this one depends on, not scope-crept into here.
Trace-context propagation into the sub-request
Authorino propagates the trace into metadata callouts; PPE cannot today. The request.* extension carries trace and span ids (docs/content/extensions.md, Request extension → request.trace_id / request.span_id), but nothing injects them: HttpRequest has no trace fields and there is no traceparent handling anywhere. The host-services doc even notes the carrier should attach the request span (crates/ppe-core/src/host.rs:19) but no code does.
Proposal: add W3C traceparent (+ tracestate) injection at the transport seam so every plugin benefits, derived from request.trace_id/request.span_id, creating a child span for the callout. Because this touches HttpRequest / HostServices, land it as a small dedicated change this plugin consumes, rather than a metadata/http-only hack. Until it lands, the plugin should at minimum emit the ids on its tracing span so logs correlate.
Designed-for reuse: Kubernetes TokenReview authentication
The AuthPolicy comparison also lists Kubernetes TokenReview authentication as a PPE gap. A TokenReview is just "POST a JSON body to one HTTP endpoint and act on the JSON response", so the same HTTP-callout implementation should back it — this issue should factor the engine with that reuse in mind, not build a metadata/http-only silo.
How Authorino does it (and why it looks like it needs no config)
Authorino's evaluator (pkg/evaluators/identity/kubernetes_auth.go) is thin: it takes the inbound bearer token off the request, builds authv1.TokenReview{ Spec: { Token: reqToken, Audiences: audiencesWithDefault(host) } }, calls k8sClient.Create(ctx, tr) (a POST /apis/authentication.k8s.io/v1/tokenreviews), and reads Status.Authenticated + Status.User. It looks configuration-free because everything hard is hidden in the injected, shared k8sClient, which the manager built once from client-go's rest.InClusterConfig(). That gives Authorino, for free, because it is a Go controller running in the cluster:
- API server address from
KUBERNETES_SERVICE_HOST / KUBERNETES_SERVICE_PORT (https://kubernetes.default.svc).
- TLS trust of the API server via the cluster CA at
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt.
- Client authentication to the API server via its own ServiceAccount bearer token at
/var/run/secrets/kubernetes.io/serviceaccount/token (not mTLS, in the common in-cluster path), with client-go transparently reloading the bound (~hourly) token.
Generalizing to PPE
PPE may run outside Kubernetes, so none of the above can be assumed — which means the generic fetcher's job is exactly to make that hidden connection + auth explicit configuration. The mapping is almost one-to-one onto the contract above:
| client-go gives Authorino for free |
On the generic metadata/http core |
| API server URL from env |
url (static https://kubernetes.default.svc/apis/authentication.k8s.io/v1/tokenreviews, or env-derived) |
Cluster CA (ca.crt) for TLS trust |
CA-bundle TLS config — the item §2 flags as a gap. TokenReview makes it required, not a follow-up. |
| In-cluster/RFC1918 destination |
host transport built with with_allow_private_destinations (same as in-cluster Vault) |
| Its SA bearer token (auto-reloaded) |
auth: shared_secret { scheme: bearer, secret: sa_token }, with sa_token a secrets: file value pointing at /var/run/secrets/kubernetes.io/serviceaccount/token; token rotation rides the existing secrets host refresh |
| (alternative) client-cert / mTLS |
client cert/key TLS config — the other half of the §2 gap |
TokenReview.Spec.Token = inbound token |
request body must carry the inbound credential under review (see caveat below) |
Status.{authenticated,user} |
response JSON → subject identity, not metadata.* |
Two things make the TokenReview use not a plain metadata/http instance, and they define the seam to design for:
- It is authentication, not enrichment. Its output binds to the subject identity (
subject.id from status.user.username, subject.roles/teams from status.user.groups, gated authenticated), and it runs at identity.resolve (Unphased, pre-authz), not pre_invocation. So the clean factoring is a shared HTTP-callout core (request build + TLS + auth + cache + retry + SSRF + observability + trace) consumed by two thin output adapters: metadata/http (→ metadata.<name>.*) and an identity/kubernetes-tokenreview kind (→ subject identity). Same code path, different output binding and hook.
- It must place the inbound credential in the request body. This is the headline case of the opt-in credential interpolation from §1: the TokenReview body legitimately needs the inbound token. It is gated by
read_inbound_credentials and expressed as an explicit, named body field rather than free-form interpolation of raw_credentials.*, so the forwarding is deliberate, auditable, and scoped to the configured endpoint.
Consequence for this issue
Build the fetcher as a reusable HTTP-callout core, and promote the two TLS items (custom CA bundle and client cert/mTLS) from "candidate follow-up" (§2, Open question 5) to in-scope, because the TokenReview reuse cannot work without them. The TokenReview adapter itself (identity binding + inbound-token body field + the identity/kubernetes-tokenreview kind) can be a fast-follow issue that depends on this one — but the core must be shaped so it drops in without a rewrite. The same core also lines up the other AuthPolicy "call-an-endpoint" features (OAuth2 token introspection, UMA) as later adapters.
Fail-closed semantics
perform_http is mandatory. Withholding it stops the call rather than degrading it — a metadata evaluator that silently skipped its callout would decide without the answer it needed (docs/content/extensions.md, "Gating an action rather than a slot").
- Default
on_error: fail → a failed/timed-out/non-2xx callout denies (the plugin runs in the sequential band so it can block). Operators opt into fail-open with on_error: ignore/continue, a documented knob, exactly as elsewhere.
- Classify errors like JWKS does —
Fatal (missing transport/capability, 4xx auth failure) vs Recoverable (timeout, 5xx) — so retry/soft-fail is principled (crates/builtins/src/plugins/identity_jwt/config.rs:740).
- Cache poisoning / staleness: a cached value must only ever feed predicates, never be re-emitted as a credential;
on_stale_refresh_error makes the stale-serve decision explicit and alarmable.
Proposed config shape (full)
plugins:
- name: geo
kind: metadata/http
hooks: [metadata.fetch]
on_error: fail
capabilities: [perform_http, read_subject, read_claims, read_headers, read_request]
config:
method: GET
url: "https://geo.example.com/v1/users/{subject.id}/location"
query:
- { name: ip, from: http.request_headers.x-forwarded-for }
headers:
- { name: x-tenant, from: claim.tenant }
timeout_seconds: 5
connect_timeout_seconds: 2
max_response_bytes: 1048576
insecure_http: false
auth:
kind: oauth2_client_credentials
token_endpoint: "https://idp.example.com/realms/x/protocol/openid-connect/token"
client_id: ppe-metadata
client_secret: metadata_client_secret
scopes: [metadata.read]
token_cache: { ttl_seconds: 3600 }
response:
type: json
cache:
enabled: true
key: [subject.id, claim.tenant]
ttl_seconds: 300
respect_cache_control: true
max_entries: 10000
on_stale_refresh_error: serve_last_good
secrets:
providers: { local: { kind: file, base_dir: /etc/ppe } }
values: { metadata_client_secret: { provider: local, ref: metadata.client-secret } }
routes:
- tool: get_quote
authentication: [corp-jwt]
authorization:
pre_invocation:
- when: "authenticated && exists(subject.id)"
do: "run(geo)"
- "metadata.geo.country in allowed_regions: allow"
Authoring notes (for the implementer)
- Template:
identity/jwt. KIND const + PluginFactory (crates/builtins/src/plugins/identity_jwt/factory.rs:64), serde config with #[serde(deny_unknown_fields)], Plugin/HookHandler (crates/ppe-core/src/plugin.rs:74, crates/ppe-core/src/hooks/trait_def.rs:135).
- New Cargo feature (e.g.
metadata-http), added to the builtins umbrella, and one line in register_builtins! (crates/ppe/src/lib.rs:180-229).
- New Metadata extension +
read_metadata capability + CMF extractor (crates/ppe-apl-cmf/src/), publishing metadata.<name>.* into the bag.
- Unknown
kind:, a declared-but-unreached plugin, and an unknown config key all fail the load by name — keep that contract.
Acceptance criteria
Open questions
- Dynamic cache keys vs
cache/key.rs policy. Confirm that a metadata (non-credential) cache may key on derived identity / request data that the credential cache forbids, and nail the invariant that cached metadata is never re-emitted as a credential. This is the central security review item.
- Hook model. A dedicated
Unphased metadata.fetch hook invoked from pre_invocation, or model the whole thing as a pre_invocation step? Former matches identity.resolve/token.delegate; confirm preference.
- Metrics seam dependency. Should this block on a general evaluator-metrics seam, or ship with
tracing+audit now and add metrics when the seam lands?
- Trace propagation scope. Land W3C
traceparent injection at the transport seam (benefits all plugins) as a prerequisite, vs plugin-local for now?
- Per-plugin TLS (CA / mTLS). Confirmed in scope (the TokenReview reuse needs it) — the open part is shape: a per-plugin TLS config struct, vs extending the host transport, vs both. Where does CA-bundle/client-cert config live so one process still shares one trust store where it wants to?
- Distributed cache. In-process LRU only for v1, or is a shared (valkey) metadata cache in scope — noting the current
SessionStore can't hold it?
- TokenReview split. Land the reusable HTTP-callout core here and the
identity/kubernetes-tokenreview adapter as a dependent fast-follow, or build both together? Either way, is identity/kubernetes-tokenreview the right kind, and does the inbound-token-in-body exception (gated by read_inbound_credentials) belong in the core or only in that adapter?
Summary
Add a builtin that lets a policy issue a sub-request to an external HTTP service during request processing and use the response in the authorization decision — AuthPolicy's "generic HTTP" external metadata, adapted to PPE's embedded, capability-gated plugin model.
Concretely: a new plugin
kind: metadata/http, invoked fromauthorization.pre_invocationas arun(name)step, that calls a user-defined endpoint (with request attributes mixed into URL / query / headers / body), parses the response, and publishes it into the attribute bag under a newmetadata.<name>.*namespace so downstream predicates, PDPs (cel/opa/cedar), andassertions:can read it — typically combined with identity already resolved (subject.*,claim.*).Motivation — parity gap
This closes the External metadata gap of the AuthPolicy/PPE feature comparison.
It also exercises three cross-cutting items that are currently weaker in PPE than in the AuthPolicy/Authorino and that matter specifically for a network callout:
How it differs from what already exists
PPE already does authenticated/cached outbound HTTP in several places; this plugin should reuse that machinery, not reinvent it, and is distinct from each:
identity/jwtJWKS fetching (crates/builtins/src/plugins/identity_jwt/config.rs:248) is the closest technical analog for HTTP-fetch-with-refresh-and-conditional-revalidation, but it is unauthenticated and fetches keys, not per-request metadata.delegator/oauth(crates/builtins/src/plugins/delegator_oauth/) already implements the OAuth2 client-credentials grant and a result cache — the two pieces this plugin needs for authenticated callouts.Where it fits in the pipeline
Unphasedhook (fires once per request, likeidentity.resolveandtoken.delegate— seecrates/ppe-core/src/hooks/metadata.rs:106), invoked from anauthorization.pre_invocationstep withrun(name). This places the callout after identity resolution and before the authorization decision, matching AuthPolicy'smetadataphase ordering.metadata.<instance-name>.*(dotted JSON), gated by a newread_metadataread capability (consistent with the capability catalog indocs/content/extensions.md). Rationale for a dedicated namespace rather than reusingclaim.*/custom.*: the source is neither a verified claim nor host-defined custom state; keeping it separate preserves least-privilege gating and keeps audit/provenance honest.PluginResult::modify_payload(...)(crates/builtins/src/plugins/identity_jwt/resolver.rs:842,927); add a typed slot + a CMF extractor incrates/ppe-apl-cmf/src/so the response surfaces in the flatAttributeBag.Why a new namespace rather than the existing
custom.*PPE already ingests host/filter-chain metadata through the
custom.*namespace: the host populatesExtensions.customandextract_custom(crates/ppe-apl-cmf/src/custom.rs:19) bridges it into the bag. That slot is deliberatelyUnrestricted+Mutable,read_cap: None(crates/ppe-core/src/extensions/filter.rs:124) — ungated precisely because the host authored it from inside the trust boundary. External-HTTP metadata is a different category and should not reusecustom.*:custom.*is host-vouched; this data came off the network (SSRF-guarded, authenticated, cacheable, can fail/stale). Routing it through the trusted host channel erases where the attribute came from — bad for audit and for trust reasoning.custom.*. Network-fetched enrichment can carry sensitive remote data and should be gated the same way;custom.*is ungated by construction.customisMutable; a fetched answer should beImmutableonce resolved so a later plugin can't rewrite another service's response before the decision.metadata.<name>.*sub-namespaces; the flat host-ownedcustommap invites key collisions.customtoday (write_cap: None; passed through on clone), so "merge" is not even the cheaper option.Hence a dedicated Metadata extension →
metadata.<name>.*, gated byread_metadata(open sub-question: gated vs ungated — recommended gated, because it crosses a network boundary; PDPs that read it then declare the cap).Example wiring:
The fetching contract
1. HTTP protocol
Everything maps onto the existing transport request type
HttpRequest(crates/ppe-core/src/http.rs:83), so most of this is config → builder:method: GET|POSTHttpRequest.methodurlHttpRequest.urlhttps://geo/v1/users/{subject.id}/loc.query: [{name, from}]urlfromis a bag key; multi-valued sources (StringSet) repeat the param.headers: [{name, from | value}]HttpRequest.header()value) or dynamic (from: <bag key>).body: {template, content_type}HttpRequest.bodyform_urlencodehelper (http.rs:222) available for form bodies.timeout_seconds(default 5)HttpRequest.timeoutDEFAULT_TIMEOUT = 5s(http.rs:54) → closes Configurable timeout.connect_timeout_seconds(default 2)HttpRequest.connect_timeoutDEFAULT_CONNECT_TIMEOUT = 2s.max_response_bytes(default 1 MiB)HttpRequest.max_response_bytesDEFAULT_MAX_RESPONSE_BYTES = 1 MiB(http.rs:71); over-limit →HttpTransportError::ResponseTooLarge→ closes Configurable maximum response size.Attribute interpolation must be constrained to bag keys the plugin's declared read capabilities unlock (so a template cannot exfiltrate
claim.*the instance was never granted). Credential-bearing namespaces (raw_credentials.*,http.request_headers.authorization) are the sharp case, and they span two distinct uses — both of which should be the operator's call, not PPE's:Authorizationheader) — token forwarding / impersonation.Rather than a hard ban on either, interpolating a credential namespace is off by default and opt-in: it requires both the matching capability (
read_inbound_credentialsforraw_credentials.*,read_headersfor the authorization header) and an explicit config acknowledgement, and the use is audited. This keeps the safe default without PPE deciding these patterns are off-limits — that call is the operator's.The call itself goes through the host-services seam exactly as every other plugin does —
svc.http_request(req, retry_policy)(crates/ppe-core/src/host.rs:178); PPE performs no HTTP of its own. The plugin must declareperform_httpor the engine refuses to start and names the missing capability (crates/ppe-core/src/host.rs:344,docs/content/extensions.md"Gating an action rather than a slot").Retry safety. Use
RetryPolicy::idempotent()for GET andundelivered_only()for POST, keyed offHttpTransportError::may_have_reached_peer()(http.rs:440) — same discipline as the Vault provider (crates/builtins/src/secrets/vault/provider.rs:262).2. TLS configurability
TLS trust is owned by the host-installed transport, not the plugin — this is a deliberate, documented property of the seam (
crates/ppe-core/src/http.rs:471): one process, one connection pool, one trust store, one egress policy.insecure_http: bool(defaultfalse, rejectshttp://), consistent withidentity/jwt,delegator/oauth, andvault(crates/builtins/src/plugins/delegator_oauth/config.rs:62).HyperTransportpins webpki-roots with.with_no_client_auth()(crates/ppe/src/http_hyper.rs:254) and exposes none of these as YAML. A deployment that needs a private CA or mTLS to the metadata source does so by injecting its ownHttpTransportviaPolicyEngine::set_http_transport. A per-plugin TLS config struct (CA path, client cert/key, SNI) is a cross-cutting transport change rather than plugin-local — but it is in scope here, not a follow-up, because the Kubernetes TokenReview reuse (see "Designed-for reuse") cannot connect to the API server without a custom CA bundle and, for the mTLS variant, a client cert.SSRF. The bundled transport refuses loopback / RFC1918 / link-local (incl.
169.254.169.254) / CGNAT100.64.0.0/10both pre-connect on IP literals and at DNS resolution time (crates/ppe-core/src/http_addr.rs:44,crates/ppe/src/http_hyper.rs:350), surfacing asHttpTransportError::Rejected. A metadata source on a private/in-cluster address therefore requires the host to build the transport withwith_allow_private_destinations(http_hyper.rs:216) — the same constraint in-cluster Vault already carries. Document it; do not add a YAML hatch.3. Authentication to the metadata source
A first-class
auth:block (tagged enum,#[serde(tag = "kind")], matching theClientSecretSource/DecodingKeySourceconvention). There is currently no generic outbound-credential primitive in the codebase — each plugin rolls its own — so this block is net-new and should become the reusable shape.(a) Shared secret — a static credential attached to the callout, with the secret resolved through the
secrets:block by value name, never a raw ref (docs/content/configuration.md, "Secrets and key material"):Hold resolved secret material in
Zeroizing<String>asdelegator/oauthdoes (crates/builtins/src/plugins/delegator_oauth/delegator.rs:48). For backward compatibility also accept the per-pluginclient_secret_sourceenum (env_var/file/literal).(b) OAuth2 client-credentials — obtain an access token (RFC 6749 §4.4) and attach it to the callout, reusing
delegator/oauth's machinery (GRANT_TYPE_CLIENT_CREDENTIALS,crates/builtins/src/plugins/delegator_oauth/delegator.rs:74;form_urlencodefor the body; HTTP Basic client auth):The fetched token is cached independently of the response cache (§4), with TTL derived from the token response
expires_incapped byttl_seconds.4. Supported response types
json(default) — parse body, publish the object asmetadata.<name>.*(dotted flatten; scalars/arrays map toAttributeValuevariants incrates/ppe-apl-core/src/attributes.rs:32). Non-2xx or unparseable body → evaluator error (see fail-closed, below).text— publish the raw string asmetadata.<name>(single scalar).Content-Typefor parse selection; reject a body exceedingmax_response_bytesbefore parsing.metadata.<name>.$statusandmetadata.<name>.$headers.*(operator opt-in) so a policy can branch on the callout's HTTP status without the plugin having to deny on non-2xx itself.Cross-cutting capabilities
Caching of metadata responses (user-defined, dynamic keys)
This is the most important capability and the one with the sharpest open design question, because PPE deliberately forbids dynamic cache keys today.
What exists. The only result cache is
delegator/oauth'sDelegatedTokenCache(crates/builtins/src/plugins/delegator_oauth/cache/). Its key derivation (cache/key.rs) is HMAC-SHA256 under a per-process secret and deliberately excludes derived identity (security.subject.id) and refuses to cache when a request-data-bearing value (a rendered template) is in the key (NotCacheable::ResourceTemplate,cache/key.rs:19-45,346).DispatchCache(crates/ppe-apl-runtime/src/dispatch_plan.rs:741) caches compiled route plans, not results, and the valkey session store (crates/ppe-apl-runtime/src/session_store.rs:68) is a monotonic label union, not a general KV cache.What this needs. Authorino caches metadata keyed on operator-chosen dynamic values (e.g.
auth.identity.username). That is exactly whatkey.rsrefuses to do — but the refusal is specific to a credential cache, where serving a cached token to the wrong principal is privilege escalation. A metadata response cache is different in kind: its payload is read-only enrichment, not a credential. Proposed resolution:Design points to settle (see Open questions):
subject.idis sound for enrichment iff (i) the key captures every attribute that varies the response, and (ii) the cached value is only ever used as enrichment feeding predicates, never re-emitted as a credential. This differs intentionally from the credential cache; citecache/key.rs:19-45for the contrast and document why metadata relaxes it.CacheKeyconstruction so cache keys are not guessable/forgeable, but allow derived-identity and request-data inputs that the token cache forbids.ETag+If-None-Match,304as hit) already has a working implementation to copy from JWKS fetch (crates/builtins/src/plugins/identity_jwt/config.rs:442;HttpResponse::etag()/cache_max_age()/is_not_modified(),crates/ppe-core/src/http.rs:331-363).DelegatedTokenCacheand the api-key lookup cache). A distributed/shared cache (valkey) is a follow-up and would need a new store interface — the currentSessionStorecannot hold arbitrary values.Conditional triggering / skipping the callout
No per-plugin "skip unless condition" exists; gating is done by the surrounding APL rule predicate over the attribute bag — which is sufficient and idiomatic:
Predicate leaves available:
Comparison,IsTrue/IsFalse,exists(key),x in set(crates/ppe-apl-core/src/rules.rs:92). This covers AuthPolicy's "conditions" on a metadata source (skip when an attribute is already present, restrict to certain roles/paths, etc.). The plugin itself should additionally short-circuit to a cache hit before issuing the callout, so "skip the sub-request when we already have the answer" holds even inside a single request.Observability — metrics and auditing
Current state (important). There are no metrics and no OpenTelemetry in the tree — the only observability dependency is
tracing = "0.1"(Cargo.toml:102); noprometheus/metrics/opentelemetrycrates, and no#[instrument]. Plugins emittracing::logs only. So this issue cannot "just add a Prometheus counter"; it should:tracingevents for every callout —outcome(hit/miss/fetched/error), HTTPstatus,latency_ms,cache(hit/miss/revalidated), and the instancename— so behavior is observable via logs immediately. Emit an audit event per callout (the audit-logger reference plugin,reference/plugins/audit-logger/, is the sink pattern).Trace-context propagation into the sub-request
Authorino propagates the trace into metadata callouts; PPE cannot today. The
request.*extension carries trace and span ids (docs/content/extensions.md, Request extension →request.trace_id/request.span_id), but nothing injects them:HttpRequesthas no trace fields and there is notraceparenthandling anywhere. The host-services doc even notes the carrier should attach the request span (crates/ppe-core/src/host.rs:19) but no code does.Proposal: add W3C
traceparent(+tracestate) injection at the transport seam so every plugin benefits, derived fromrequest.trace_id/request.span_id, creating a child span for the callout. Because this touchesHttpRequest/HostServices, land it as a small dedicated change this plugin consumes, rather than ametadata/http-only hack. Until it lands, the plugin should at minimum emit the ids on itstracingspan so logs correlate.Designed-for reuse: Kubernetes TokenReview authentication
The AuthPolicy comparison also lists Kubernetes TokenReview authentication as a PPE gap. A TokenReview is just "POST a JSON body to one HTTP endpoint and act on the JSON response", so the same HTTP-callout implementation should back it — this issue should factor the engine with that reuse in mind, not build a
metadata/http-only silo.How Authorino does it (and why it looks like it needs no config)
Authorino's evaluator (
pkg/evaluators/identity/kubernetes_auth.go) is thin: it takes the inbound bearer token off the request, buildsauthv1.TokenReview{ Spec: { Token: reqToken, Audiences: audiencesWithDefault(host) } }, callsk8sClient.Create(ctx, tr)(aPOST /apis/authentication.k8s.io/v1/tokenreviews), and readsStatus.Authenticated+Status.User. It looks configuration-free because everything hard is hidden in the injected, sharedk8sClient, which the manager built once from client-go'srest.InClusterConfig(). That gives Authorino, for free, because it is a Go controller running in the cluster:KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT(https://kubernetes.default.svc)./var/run/secrets/kubernetes.io/serviceaccount/ca.crt./var/run/secrets/kubernetes.io/serviceaccount/token(not mTLS, in the common in-cluster path), with client-go transparently reloading the bound (~hourly) token.Generalizing to PPE
PPE may run outside Kubernetes, so none of the above can be assumed — which means the generic fetcher's job is exactly to make that hidden connection + auth explicit configuration. The mapping is almost one-to-one onto the contract above:
metadata/httpcoreurl(statichttps://kubernetes.default.svc/apis/authentication.k8s.io/v1/tokenreviews, or env-derived)ca.crt) for TLS trustwith_allow_private_destinations(same as in-cluster Vault)auth: shared_secret { scheme: bearer, secret: sa_token }, withsa_tokenasecrets:file value pointing at/var/run/secrets/kubernetes.io/serviceaccount/token; token rotation rides the existingsecretshost refreshTokenReview.Spec.Token= inbound tokenStatus.{authenticated,user}metadata.*Two things make the TokenReview use not a plain
metadata/httpinstance, and they define the seam to design for:subject.idfromstatus.user.username,subject.roles/teams fromstatus.user.groups, gatedauthenticated), and it runs atidentity.resolve(Unphased, pre-authz), notpre_invocation. So the clean factoring is a shared HTTP-callout core (request build + TLS + auth + cache + retry + SSRF + observability + trace) consumed by two thin output adapters:metadata/http(→metadata.<name>.*) and anidentity/kubernetes-tokenreviewkind (→ subject identity). Same code path, different output binding and hook.read_inbound_credentialsand expressed as an explicit, named body field rather than free-form interpolation ofraw_credentials.*, so the forwarding is deliberate, auditable, and scoped to the configured endpoint.Consequence for this issue
Build the fetcher as a reusable HTTP-callout core, and promote the two TLS items (custom CA bundle and client cert/mTLS) from "candidate follow-up" (§2, Open question 5) to in-scope, because the TokenReview reuse cannot work without them. The TokenReview adapter itself (identity binding + inbound-token body field + the
identity/kubernetes-tokenreviewkind) can be a fast-follow issue that depends on this one — but the core must be shaped so it drops in without a rewrite. The same core also lines up the other AuthPolicy "call-an-endpoint" features (OAuth2 token introspection, UMA) as later adapters.Fail-closed semantics
perform_httpis mandatory. Withholding it stops the call rather than degrading it — a metadata evaluator that silently skipped its callout would decide without the answer it needed (docs/content/extensions.md, "Gating an action rather than a slot").on_error: fail→ a failed/timed-out/non-2xx callout denies (the plugin runs in thesequentialband so it can block). Operators opt into fail-open withon_error: ignore/continue, a documented knob, exactly as elsewhere.Fatal(missing transport/capability,4xxauth failure) vsRecoverable(timeout,5xx) — so retry/soft-fail is principled (crates/builtins/src/plugins/identity_jwt/config.rs:740).on_stale_refresh_errormakes the stale-serve decision explicit and alarmable.Proposed config shape (full)
Authoring notes (for the implementer)
identity/jwt.KINDconst +PluginFactory(crates/builtins/src/plugins/identity_jwt/factory.rs:64), serde config with#[serde(deny_unknown_fields)],Plugin/HookHandler(crates/ppe-core/src/plugin.rs:74,crates/ppe-core/src/hooks/trait_def.rs:135).metadata-http), added to thebuiltinsumbrella, and one line inregister_builtins!(crates/ppe/src/lib.rs:180-229).read_metadatacapability + CMF extractor (crates/ppe-apl-cmf/src/), publishingmetadata.<name>.*into the bag.kind:, a declared-but-unreached plugin, and an unknown config key all fail the load by name — keep that contract.Acceptance criteria
kind: metadata/httpregisters behind its feature and viainstall_builtins.timeout_seconds,connect_timeout_seconds,max_response_bytes.insecure_httpknob; private-destination/SSRF behavior documented.identity/kubernetes-tokenreviewadapter can consume without a rewrite; inbound-token-in-body is gated byread_inbound_credentials.auth: shared_secret(bearer / custom header / basic) viasecrets:values.auth: oauth2_client_credentialswith token caching.metadata.<name>.*and readable bycel/opa/cedarandassertions:.Cache-Controlrevalidation.when:+ in-request cache short-circuit.on_error: faildenies by default;ignore/continuedocumented fail-open.tracingevents + audit event per callout; target metric set and trace-propagation design captured (even if gated on the cross-cutting work).docs/content/page + a worked example, held by a loaded fixture.Open questions
cache/key.rspolicy. Confirm that a metadata (non-credential) cache may key on derived identity / request data that the credential cache forbids, and nail the invariant that cached metadata is never re-emitted as a credential. This is the central security review item.Unphasedmetadata.fetchhook invoked frompre_invocation, or model the whole thing as apre_invocationstep? Former matchesidentity.resolve/token.delegate; confirm preference.tracing+audit now and add metrics when the seam lands?traceparentinjection at the transport seam (benefits all plugins) as a prerequisite, vs plugin-local for now?SessionStorecan't hold it?identity/kubernetes-tokenreviewadapter as a dependent fast-follow, or build both together? Either way, isidentity/kubernetes-tokenreviewthe rightkind, and does the inbound-token-in-body exception (gated byread_inbound_credentials) belong in the core or only in that adapter?