#4675·higress

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

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

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

  • Plugin: plugins/wasm-go/extensions/ext-auth
  • Baseline: current main (VERSION = 2.0.0)
  • issue-spec change: ext-auth-capability-alignment

Background

Today ext-auth calls the external authorization service on every request, decides allow/deny purely from the HTTP status code (statusCode == 200 means allow), and on allow can only forward the authorization response headers matched by allowed_upstream_headers to the upstream under their original names.

Three problems show up when integrating real authorization services:

  1. Every request hits the authorization service. When the same credential is used repeatedly in a short window, the authorization service absorbs the same QPS as the business traffic. That both inflates its capacity cost and adds its RT to every single business request.
  2. The status code alone is not enough. Many authorization services — especially in-house ones built on a shared framework or a BFF — return 200 whether or not authorization succeeded, and put the real verdict in the response body, e.g. {"data":{"code":"OK","uid":"1024"}}. Such a service cannot be wired to ext-auth today without first changing it to express the verdict through the status code.
  3. Response headers can only be forwarded under the same name. The identity a service computes (user id, tenant, permission bits) often lives in the response body, or its header name differs from what the upstream expects. Today the plugin can neither read a body field nor rename a header, so the upstream has to parse it again or call the authorization service a second time.

Goals

Add three optional capabilities to ext-auth while keeping full backward compatibility:

  1. Auth result caching: cache a successful authorization result with a TTL; on a hit, allow the request without calling the authorization service.
  2. Allow/deny from response content: let the allow decision be composed from the authorization response's status code, headers, and body JSON fields.
  3. Response field forwarding: read a value from the authorization response's status code, headers, or body JSON fields, and inject it into the upstream request under a configured header name.

Constraints:

  • Every new field is optional. With none of them configured, plugin behavior is byte-for-byte identical to current main.
  • Follow the existing Higress configuration style: structured typed fields plus native gjson paths (as in transformer), with no custom expression-string syntax.
  • Caching must fail open. The cache is an optimization, not part of the authorization chain: any cache or Redis problem degrades to "ask the real authorization service". It must never block the request, and it must never skip authorization.

Design Sketch

Proposed configuration shape; the full design lives in the follow-up Design Issue:

yaml
http_service:
  authorization_response:
    # existing fields, semantics unchanged
    allowed_upstream_headers:
      - exact: x-user-id
    allowed_client_headers:
      - exact: www-authenticate

    # new: forward response fields upstream, renaming allowed
    mapped_upstream_headers:
      - source: body_json
        key: data.uid
        to_header: x-auth-user-id
      - source: header
        key: x-user-token
        to_header: x-auth-token

  # new: decide allow/deny from response content; flat list = implicit AND
  success_condition:
    - source: status_code
      op: eq
      value: "200"
    - source: body_json
      key: data.code
      op: eq
      value: "OK"
    - source: body_json
      key: data.uid
      op: exists

# new: auth result cache, top level, disabled by default
cache:
  enabled: true
  ttl: 300
  # optional: narrow the cache key to chosen request fields (default: the full forwarded request)
  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

Key points:

  • source is one of status_code / header / body_json, and the same extraction semantics are shared by all three new capabilities. key is the header name for header and a native gjson path for body_json (for example data.uid, friends.1.first).
  • success_condition is a flat list whose entries are ANDed implicitly. op supports eq / ne / in / not_in / exists / not_exists / gt / lt. When it is absent, behavior is equivalent to today's "200 means allow".
  • success_condition is evaluated after the existing statusCode == 200 check. When it is not satisfied, the request is rejected with the existing status_on_error; no new rejection semantics are introduced.
  • mapped_upstream_headers coexists with the existing allowed_upstream_headers and does not affect it. A value that cannot be extracted is not injected.
  • The cache stores allow decisions only, never denials, and is skipped when with_request_body: true (the request body takes part in the decision, so no cache key can safely represent it). By default (cache.key_fields unset) the cache key structurally covers everything the decision depends on (method + path including query + the sorted authorization request headers), so the credential is inherently part of it and results cannot be shared across credentials.
  • An optional cache.key_fields lets an admin narrow the key to chosen request fields (source: header / query): the key then becomes method + path-without-query + the listed field values, and query / Authorization / forward_auth headers are no longer folded in automatically. This is for callers whose credential is per-request-unique (a signed or timestamped Authorization), where the default key would make every entry unique so the cache never hits. The trade-off is a responsibility shift: once key_fields is set, the admin must list every field the decision depends on, since omitting a validated credential would allow a replay window within the TTL.

Scope

This change touches a single plugin, plugins/wasm-go/extensions/ext-auth: three new optional configuration fields plus their runtime behavior, unit tests, and the plugin README. It does not touch the gateway core, other plugins, or the authorization call protocol.

In Scope

  • success_condition: configuration parsing, value extraction, condition evaluation, wired into the existing rejection path.
  • mapped_upstream_headers: configuration parsing, and injection of the upstream request header named by to_header on allow.
  • cache: configuration parsing and Redis client wiring, fail-open end to end (short GET timeout, errors and corrupt content treated as MISS, SET failures swallowed).
  • Unit tests plus README.md / README_EN.md updates.

Out of Scope

  • OR / nesting / arithmetic / a full expression engine for success_condition. The first iteration ships the implicit-AND subset only. If OR is needed later it can be added backward-compatibly by wrapping the list as {match_type: and|or, rules: [...]}.
  • Forwarding into query / path / form. The first iteration only injects request headers.
  • Allow/deny lists backed by an external dataset. Orthogonal to this proposal; a separate change can carry it.
  • Changing the authorization call protocol itself, or the semantics of existing fields such as allowed_upstream_headers / allowed_client_headers / headers_to_add / with_request_body.
  • Changing VERSION. Per docs/developers/immutable-plugin-releases.md, plugin versions are proposed by the managed release preparation PR, so this PR leaves VERSION untouched.

Related Specs Analysis

This repository leaves durable_specs unset in issue-spec/config.yaml, so there is no existing durable capability spec to revise or inherit. The behavioral contract for this change is carried by the SPEC typed comments on this Proposal:

  • SPEC-4675001: allow/deny evaluation through success_condition.
  • SPEC-4675002: value extraction and renamed forwarding through mapped_upstream_headers.
  • SPEC-4675003: auth result caching and fail-open degradation through cache.
  • SPEC-4675004: backward compatibility of the new fields and unchanged semantics of existing fields.

Relationship to existing plugin behavior: success_condition is a superset of "decide from the status code only" and degrades to today's behavior when unset. mapped_upstream_headers is complementary to allowed_upstream_headers (the latter filters and forwards under the same name; the former extracts across sources and renames); it neither replaces nor overrides it. cache is a bypass placed in front of the authorization call and does not change the authorization call protocol.

For configuration style this follows the native gjson paths used by the transformer plugin, and the Redis configuration and client usage of the cluster-key-rate-limit / ai-cache plugins. No new configuration paradigm is introduced.

Existing Assumptions Impact

This change touches the following existing assumptions, and handles them as follows:

  1. "A 200 from the authorization service means allow." success_condition lets that check be tightened (conditions are evaluated after the 200). The assumption is fully preserved when the field is unset. It is never loosened: this change introduces no path where a non-200 leads to allow.
  2. "Authorization-derived upstream headers only come from same-named response headers." With mapped_upstream_headers the upstream may receive headers sourced from the response body and under a different name. This happens only when explicitly configured, and to_header is always supplied by the user — the plugin never synthesizes a header name.
  3. "Every request really calls the authorization service." On a cache hit this no longer holds. Therefore the cache only takes effect when explicitly enabled, stores allow decisions only, and is skipped when with_request_body: true; in the default key mode the cache key structurally includes the credential so a verdict is never reused across credentials (with the optional key_fields, covering every decision input becomes the admin's responsibility).
  4. "The plugin depends on no external storage." With caching enabled the plugin depends on Redis. The Redis client is not initialized when the gate does not pass, and every Redis problem fails open to a real authorization call, so cache availability is not introduced as a risk to the authorization chain.
  5. "The authorization response body is only used on with_request_body related paths." The new capabilities read the authorization response body. This is limited to the authorization service's response and does not change how the client request body is read, nor the semantics of max_request_body_bytes.

Acceptance Criteria

  • Regression guard: with none of the new fields configured, behavior is identical to current main.
  • success_condition: 200 and all conditions satisfied → allow; 200 with any condition unsatisfied → reject with status_on_error; non-200 → the existing error handling path is unaffected.
  • mapped_upstream_headers: a value read from body / header / status is injected upstream under to_header; a value that cannot be extracted is not injected; existing same-name forwarding is unaffected.
  • cache: Redis is not touched when the cache is unset or with_request_body: true; MISS → call authorization → allow → write cache; HIT → no authorization call → apply the cached injection set → allow; GET error / timeout / corrupt content → fail open to an authorization call; a denial is not cached; a SET failure does not affect the allow decision; in the default key mode, different credentials produce different cache keys.
  • go test ./... passes and coverage meets the project requirement.

The concrete Verification Plan (exact commands, expected results, evidence) is given in the Design Issue and governs implementation verification.

Risks

  • Cross-credential cache reuse: the cache key must cover every input the authorization decision depends on. In the default key mode this holds structurally: the request body never enters the key, and enabling with_request_body disables caching altogether. The optional key_fields narrows the key for callers with per-request-unique credentials; once set, covering every decision input becomes the admin's responsibility (documented in the README), since an omitted validated credential would allow a replay window within the TTL.
  • A flaky Redis dragging down the request path: GET must carry a short timeout, and every error degrades to calling the real authorization service. Fail-open here means "bypass the cache", not "bypass authorization".
  • Dependencies: github.com/tidwall/gjson (already a dependency); the wasm-go SDK Redis client (usage modeled on cluster-key-rate-limit / ai-cache).

AI Participation Disclosure

This contribution is material agent participation as defined by docs/developers/agent-assisted-contributions.md: the analysis, design, and implementation are produced with a coding agent and reviewed by the author. The author holds neither maintain nor admin on higress-group/higress, so the verified maintainer/administrator exception does not apply and the full issue-spec workflow is followed: implementation will not start until a Higress maintainer explicitly approves this Proposal and the follow-up Design Issue.