#4676·higress

Design: ext-auth: cache auth results, decide allow/deny from response content, and forward response fields upstream

Author: learnerjohnCreated Sep 10, 2026Updated Sep 17, 2026

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 extracting filterAllowedClientHeaders out of callExtAuthServerErrorHandler.
  • 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 between allowed_upstream_headers and mapped_upstream_headers.

Explicitly not touched:

  • The authorization call protocol, forward_auth / envoy mode semantics, headers_to_add, with_request_body, max_request_body_bytes, match_type / match_rules, failure_mode_allow, status_on_error defaults.
  • VERSION (owned by the managed release-preparation PR per docs/developers/immutable-plugin-releases.md).
  • The expr package.

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 + key per rule (chosen). Matches the transformer plugin's inline fromKey / toKey style, is directly validatable by a console schema, and needs no cross-reference resolution.
  • B. Named-value indirection: a value_extractions block defining named values, referenced by value_name from 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 the error-mapping plugin). 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

  1. Value extraction is inlined as source (status_code | header | body_json) plus key, shared by C2 and C3 through one helper.
  2. success_condition is a flat list with implicit AND, evaluated only after the existing 200 check passes.
  3. 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".
  4. 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.
  5. The inline allowed_client_headers filter is extracted into filterAllowedClientHeaders(cfg, extAuthRespHeaders) http.Header as a pure refactor, so both the existing error path and the new condition-rejection path share it.
  6. cache sits at the top level, is off by default, only caches allow decisions, and never enters its code path when with_request_body is on.
  7. The cache fails open in every direction, and fail-open always means "ask the real authorization service", never "let the request through".
  8. 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 listed header/query field values, so a per-request-unique credential (e.g. a signed Authorization) 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.

yaml
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 authorization

Field placement:

  • mapped_upstream_headers sits under authorization_response, next to allowed_upstream_headers — both answer "what to do with the authorization response".
  • success_condition sits under http_service — it decides "whether to allow", a different responsibility from authorization_response's header handling, and it reads naturally as a sibling of endpoint / timeout.
  • cache sits 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.ReplaceHttpRequestHeader for allowed_upstream_headers there and then proxywasm.ResumeHttpRequest(). Both arrival paths (with a body: onHttpRequestBody -> DataStopIterationAndBuffer; without: onHttpRequestHeaders -> HeaderStopAllIterationAndWatermark) converge in this callback, and C3 reuses the same mechanism.
  • onHttpRequestHeaders already calls ctx.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:

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/gjson is already a plugin dependency (imported by config/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:

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 []Condition

ExtAuthConfig 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:

go
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 + ResumeHttpRequest

When success_condition is not configured, evaluation is skipped and the behavior is identical to the baseline.

C3: mapped_upstream_headers

go
type HeaderMapping struct {
    Source   string
    Key      string
    ToHeader string
}
// AuthorizationResponse gains the field MappedUpstreamHeaders []HeaderMapping

Parsed by parseAuthorizationResponseConfig. Callback change, appended after the existing forwarding loop:

go
// 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" + sortedExtAuthReqHeaders
  • sortedExtAuthReqHeaders is the output of buildExtAuthRequestHeaders, 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 by allowed_headers, and the x-original-* headers in forward_auth mode). 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_body disables 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" + sortedKeyFieldValues
  • cache.key_fields is an optional list of {source, key} items, sourceheader | 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-array key_fields, a non-object item, a source other than header/query, or an empty key) reject the whole plugin configuration at parse time, with no silent degradation — consistent with success_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. Likewise Authorization and the forward_auth-injected x-original-* / x-forwarded-* headers are not included automatically — only when listed as source: header.
  • Why this mode exists, and the responsibility shift. The default mode ties the key to the full forwarded request, which is