Design: ext-auth: cache auth results, decide allow/deny from response content, and forward response fields upstream
ext-auth: cache auth results, decide allow/deny from response content, and forward response fields upstream
- Proposal Issue: 4675
Context
- Plugin:
plugins/wasm-go/extensions/ext-auth - Baseline: current
main(VERSION= 2.0.0) - Proposal: #4675 (behavior contracts in its
SPEC-4675001~SPEC-4675004)
This design is grounded in the actual baseline code: onHttpRequestHeaders / onHttpRequestBody / checkExtAuth / buildExtAuthRequestHeaders / callExtAuthServerErrorHandler in main.go, and ParseConfig / parseHttpServiceConfig / parseAuthorizationRequestConfig / parseAuthorizationResponseConfig in config/config.go.
Goals
Add three optional capabilities while keeping full backward compatibility: decide allow/deny from response content (success_condition), extract response fields and forward them upstream with a rename (mapped_upstream_headers), and cache authorization results (top-level cache, fail-open).
Non-goals: OR / nesting / a full expression engine for success_condition; forwarding into query / path / form; dataset-backed allow/deny lists; changing the authorization call protocol or the semantics of existing fields; changing VERSION.
Current Implementation Locations
Authorization is an asynchronous HTTP callback. onHttpRequestHeaders / onHttpRequestBody trigger checkExtAuth, which calls the authorization service through httpServiceConfig.Client.Call(...), and the result is handled in a callback receiving (statusCode, responseHeaders, responseBody).
| Location | Current responsibility | Relevance to this change |
|---|---|---|
main.go onHttpRequestHeaders |
builds the ext-auth request, calls ctx.DisableReroute(), returns HeaderStopAllIterationAndWatermark when there is no request body |
already suppresses rerouting, so injected headers cannot trigger a reroute |
main.go onHttpRequestBody |
returns DataStopIterationAndBuffer and triggers checkExtAuth when with_request_body is on |
the second arrival path; both paths converge in the same callback |
main.go checkExtAuth |
builds headers via buildExtAuthRequestHeaders and issues Client.Call(...) |
the only place where the cache path can be inserted (C4) |
main.go buildExtAuthRequestHeaders |
produces the header set sent to the authorization service | its output is exactly the input the authorization decision depends on, so it is the basis of the cache key |
main.go authorization callback |
the only decision point: if statusCode != http.StatusOK { ... }, otherwise forwards allowed_upstream_headers with proxywasm.ReplaceHttpRequestHeader and calls proxywasm.ResumeHttpRequest() |
C2 inserts condition evaluation after the 200 branch; C3 appends header injection |
main.go callExtAuthServerErrorHandler |
handles non-200 and unavailable authorization services; filters allowed_client_headers inline; honors failure_mode_allow / status_on_error |
the inline client-header filter is extracted for reuse; its semantics are unchanged |
config/config.go parseHttpServiceConfig |
parses endpoint / timeout / authorization_request / authorization_response |
gains success_condition parsing |
config/config.go parseAuthorizationResponseConfig |
parses allowed_upstream_headers / allowed_client_headers into expr.Matcher |
gains mapped_upstream_headers parsing |
config/config.go ParseConfig |
parses http_service, match_rules, failure_mode_allow, status_on_error |
gains top-level cache parsing |
The baseline decides purely from the status code: 200 always means allow and the body is never inspected. There is no Redis dependency anywhere in the plugin today.
Impact Scope
In scope for code changes:
main.go: the authorization callback (condition evaluation, header injection),checkExtAuth(cache read/write path), and extractingfilterAllowedClientHeadersout ofcallExtAuthServerErrorHandler.config/config.go: three new optional config blocks plus the Redis client initialization behind the cache gate.extract/extract.go(new): the shared value-extraction helper.main_test.go,config/config_test.go: new cases; existing cases stay as regression guards.README.md,README_EN.md: document the new fields and the selection rule betweenallowed_upstream_headersandmapped_upstream_headers.
Explicitly not touched:
- The authorization call protocol,
forward_auth/envoymode semantics,headers_to_add,with_request_body,max_request_body_bytes,match_type/match_rules,failure_mode_allow,status_on_errordefaults. VERSION(owned by the managed release-preparation PR perdocs/developers/immutable-plugin-releases.md).- The
exprpackage.
Behavioral risk surface: the callback is the single convergence point for both arrival paths, so a mistake there affects every request. This is why every new branch is gated on a non-empty new config field, and why SPEC-4675004 exists as an explicit "unconfigured equals baseline" contract.
Candidate Plans
Value extraction shape.
- A. Inline
source+keyper rule (chosen). Matches thetransformerplugin's inlinefromKey/toKeystyle, is directly validatable by a console schema, and needs no cross-reference resolution. - B. Named-value indirection: a
value_extractionsblock defining named values, referenced byvalue_namefrom conditions and mappings. Rejected: an extra level of indirection and more verbose config for no gain at this scope. - C. Colon-encoded strings such as
"BodyJsonField:$.data.uid"(as used by theerror-mappingplugin). Rejected: encodes type and path inside a string, cannot be schema-validated, and does not match the gjson-native path style.
Condition expression power.
- A. Flat list with implicit AND (chosen). Covers the equality-AND shape that real authorization services need, is schema-validatable, and leaves room for a future
{match_type, rules}wrapper to add OR without breaking compatibility. - B. A string expression engine such as
$a == 200 and $b != null. Rejected for this change: requires an expression parser and a new syntax to document and validate.
Cache placement.
- A. Top-level
cache(chosen). The cache is per-request-scope infrastructure across requests, not part of the semantics of a single authorization call. - B. Under
http_service. Rejected, but this is the most debatable trade-off in this design and the one most worth a maintainer opinion.
What to store in the cache.
- A. The final decision plus the complete set of headers to inject (chosen). On a hit there is no authorization response to recompute from, so the injection set must be materialized at write time.
- B. The raw authorization response, re-evaluated on hit. Rejected: it would require replaying condition evaluation and extraction against a stored response, storing more data for no behavioral gain.
Decisions
- Value extraction is inlined as
source(status_code|header|body_json) pluskey, shared by C2 and C3 through one helper. success_conditionis a flat list with implicit AND, evaluated only after the existing 200 check passes.- A failed condition reuses the existing
status_on_error(default 403) instead of introducing a new status field, because "condition not satisfied" means "the authorization service said no". - The authorization service's 200 body is not returned to the client. The baseline forwards the authorization service's error body, which is client-facing error information; a 200 body is a success payload that may carry internal fields, so the rejection path passes
nil. - The inline
allowed_client_headersfilter is extracted intofilterAllowedClientHeaders(cfg, extAuthRespHeaders) http.Headeras a pure refactor, so both the existing error path and the new condition-rejection path share it. cachesits at the top level, is off by default, only caches allow decisions, and never enters its code path whenwith_request_bodyis on.- The cache fails open in every direction, and fail-open always means "ask the real authorization service", never "let the request through".
- The cache key is user-narrowable via an optional
cache.key_fields. Unset preserves the default full-request key (backward compatible); when set, the key becomes method + path-without-query + the listedheader/queryfield values, so a per-request-unique credential (e.g. a signedAuthorization) no longer defeats the cache. This trades the default's "key auto-tracks everything forwarded" safety for admin control: once set, the admin must list every field the decision depends on.
Delivery Structure
One PR, four ordered stages:
| Stage | Content | Depends on |
|---|---|---|
| C1 | value-extraction helper: source + key to a string value (status_code / header / body_json), shared by C2 and C3 |
none |
| C2 | success_condition evaluation |
C1 |
| C3 | mapped_upstream_headers extract-and-rename forwarding |
C1 |
| C4 | top-level cache for authorization results (gate plus fail-open) |
C2 + C3 (the cache stores the final decision and the complete injection set) |
Config Schema
Aligned with the transformer plugin: structured typed fields plus gjson-native paths (no $. prefix). No named-value indirection; source and key are inlined in every rule.
http_service:
authorization_response:
# —— existing fields, semantics unchanged ——
allowed_upstream_headers: # filter: auth response headers -> upstream, same name; matcher exact/prefix/regex
- exact: x-user-id
allowed_client_headers: # filter: auth response headers -> client, on rejection
- exact: www-authenticate
# —— new: mapper (can read the body, can rename) ——
mapped_upstream_headers:
- source: body_json # source enum: status_code | header | body_json
key: data.uid # gjson path for body_json / header name for header; omitted for status_code
to_header: x-auth-user-id # target header name injected upstream; different from the source name means rename
- source: header
key: x-user-token
to_header: x-auth-token
# —— new: success condition (flat list = implicit AND) ——
success_condition:
- source: status_code
op: eq # eq | ne | in | not_in | exists | not_exists | gt | lt
value: "200"
- source: body_json
key: data.code
op: eq
value: "OK"
- source: body_json
key: data.uid
op: exists # exists / not_exists need no value
# —— new: top-level cache (off by default, fail-open) ——
cache:
enabled: true
ttl: 300 # seconds; <=0 means off; hard cap 600 to avoid long-stale authorization verdicts
# optional: narrow the cache key to chosen request fields instead of the full forwarded request.
# unset -> default key (method + path-with-query + all forwarded headers).
# set -> key = method + path-without-query + the listed field values (absent field counts as empty).
key_fields:
- source: header # header | query
key: x-app-key
- source: query
key: userId
redis:
service_name: my-redis.static
service_port: 6379
username: ""
password: ""
database: 0
timeout: 1000 # ms; a short GET timeout keeps a hung Redis from stalling authorizationField placement:
mapped_upstream_headerssits underauthorization_response, next toallowed_upstream_headers— both answer "what to do with the authorization response".success_conditionsits underhttp_service— it decides "whether to allow", a different responsibility fromauthorization_response's header handling, and it reads naturally as a sibling ofendpoint/timeout.cachesits at the top level — it is plugin-level infrastructure spanning requests, not part of a single authorization call.
Selection rule for users (to be documented in the README): to forward response headers under their original names, use the existing allowed_upstream_headers (matcher wildcards supported); to read a body field or to rename, use mapped_upstream_headers; both may be configured together without affecting each other.
Execution Model
- The C2 and C3 logic lives entirely inside the authorization callback, not in
onHttpRequestBody. The callback's three arguments are exactly the three sources needed for evaluation and extraction. - Modifying upstream request headers inside the callback is an existing, proven mechanism: the baseline already calls
proxywasm.ReplaceHttpRequestHeaderforallowed_upstream_headersthere and thenproxywasm.ResumeHttpRequest(). Both arrival paths (with a body:onHttpRequestBody->DataStopIterationAndBuffer; without:onHttpRequestHeaders->HeaderStopAllIterationAndWatermark) converge in this callback, and C3 reuses the same mechanism. onHttpRequestHeadersalready callsctx.DisableReroute(), so injected headers do not trigger rerouting.- C2 inserts condition evaluation "after 200, before allow".
C1: value-extraction helper
New file extract/extract.go:
package extract
const (
SourceStatusCode = "status_code"
SourceHeader = "header"
SourceBodyJson = "body_json"
)
// Value extracts one string value from the authorization response by source and key.
// ok=false means nothing could be extracted: missing header, non-existent gjson path, or empty body.
func Value(source, key string, statusCode int, headers http.Header, body []byte) (string, bool) {
switch source {
case SourceStatusCode:
return strconv.Itoa(statusCode), true
case SourceHeader:
if v := headers.Get(key); v != "" {
return v, true
}
return "", false
case SourceBodyJson:
if len(body) == 0 {
return "", false
}
if g := gjson.GetBytes(body, key); g.Exists() {
return g.String(), true
}
return "", false
}
return "", false
}github.com/tidwall/gjsonis already a plugin dependency (imported byconfig/config.go), so no new library is introduced.- gjson natively supports arbitrary nesting plus array and filter syntax (
data.uid,friends.1.first,users.#.age,data.#(x>1).y). - The helper only produces values; it neither evaluates nor injects. Evaluation (C2) and forwarding (C3) each consume it.
C2: success_condition
New types in config/config.go:
type Condition struct {
Source string // status_code | header | body_json
Key string
Op string // eq | ne | in | not_in | exists | not_exists | gt | lt
Value []string // multi-valued for in/not_in; [0] otherwise; ignored for exists/not_exists
}
// SuccessCondition is a flat list, evaluated as an implicit AND.
type SuccessCondition []ConditionExtAuthConfig gains a SuccessCondition field, populated by a new parseSuccessCondition(json.Get("success_condition")) inside parseHttpServiceConfig.
Evaluation: for each entry call extract.Value(...) and compare per op; if any entry is unsatisfied the whole list is unsatisfied. gt / lt compare numerically and a parse failure counts as unsatisfied; exists / not_exists look only at ok.
Callback change in main.go, after the existing non-200 branch and before allowing:
if statusCode != http.StatusOK {
log.Errorf("failed to call ext auth server, status: %d", statusCode)
callExtAuthServerErrorHandler(cfg, statusCode, responseHeaders, responseBody) // existing, unchanged
return
}
// new: evaluate the success condition after 200
if len(cfg.SuccessCondition) > 0 &&
!evaluateSuccessCondition(cfg.SuccessCondition, statusCode, responseHeaders, responseBody) {
_ = util.SendResponse(cfg.StatusOnError, "ext-auth.denied-by-condition",
filterAllowedClientHeaders(cfg, responseHeaders), nil)
return
}
// ... existing allowed_upstream_headers forwarding + C3 injection + ResumeHttpRequestWhen success_condition is not configured, evaluation is skipped and the behavior is identical to the baseline.
C3: mapped_upstream_headers
type HeaderMapping struct {
Source string
Key string
ToHeader string
}
// AuthorizationResponse gains the field MappedUpstreamHeaders []HeaderMappingParsed by parseAuthorizationResponseConfig. Callback change, appended after the existing forwarding loop:
// existing: allowed_upstream_headers same-name forwarding (unchanged)
if resp.AllowedUpstreamHeaders != nil {
for headK, headV := range responseHeaders {
if resp.AllowedUpstreamHeaders.Match(headK) {
_ = proxywasm.ReplaceHttpRequestHeader(headK, headV[0])
}
}
}
// new: extract, then inject under the target name
for _, m := range resp.MappedUpstreamHeaders {
if v, ok := extract.Value(m.Source, m.Key, statusCode, responseHeaders, responseBody); ok && v != "" {
_ = proxywasm.ReplaceHttpRequestHeader(m.ToHeader, v)
}
}
proxywasm.ResumeHttpRequest()Nothing extracted and empty values are not injected (no empty headers are written). Coexistence with allowed_upstream_headers: same-name forwarding runs first, then the mapped injection.
C4: cache
Cache key
The key composition depends on whether cache.key_fields is configured.
Default (cache.key_fields unset) — key ≡ the full authorization request:
cacheKey = "ext-auth:cache:" + hex(sha256(canonicalString))
canonicalString = method + "\n" + path (including query) + "\n" + sortedExtAuthReqHeaderssortedExtAuthReqHeadersis the output ofbuildExtAuthRequestHeaders, sorted by key and serialized deterministically (Go map iteration order is random, so sorting is mandatory).- That header set is exactly the input the authorization service decides on (including
Authorization, the headers matched byallowed_headers, and thex-original-*headers inforward_authmode). Hashing it hashes everything the decision depends on, so credentials are inherently part of the key and users do not have to declare a cache dimension by hand. - The request body never enters the key. Combined with "enabling
with_request_bodydisables the cache", this structurally rules out cross-credential and cross-body collisions. - The query string is part of the key.
User-specified key (cache.key_fields set) — key ≡ method + path + chosen fields:
cacheKey = "ext-auth:cache:" + hex(sha256(canonicalString))
canonicalString = method + "\n" + path (excluding query) + "\n" + sortedKeyFieldValuescache.key_fieldsis an optional list of{source, key}items,source∈header|query, naming the request fields that compose the key. Values are read from the request; an absent field contributes an empty value (it is neither skipped nor an error). Invalid items (a non-arraykey_fields, a non-object item, asourceother thanheader/query, or an emptykey) reject the whole plugin configuration at parse time, with no silent degradation — consistent withsuccess_condition/mapped_upstream_headers.- The method and the path (excluding query) are always keyed and cannot be removed or overridden via
key_fields. - Unlike the default mode, the query string is not folded in wholesale: query parameters enter the key only through items explicitly listed as
source: query. LikewiseAuthorizationand theforward_auth-injectedx-original-*/x-forwarded-*headers are not included automatically — only when listed assource: header. - Why this mode exists, and the responsibility shift. The default mode ties the key to the full forwarded request, which is
Source: higress-group/higress