Proposal: 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
- 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:
- 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.
- 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 toext-authtoday without first changing it to express the verdict through the status code. - 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:
- Auth result caching: cache a successful authorization result with a TTL; on a hit, allow the request without calling the authorization service.
- Allow/deny from response content: let the allow decision be composed from the authorization response's status code, headers, and body JSON fields.
- 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:
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: 1000Key points:
sourceis one ofstatus_code/header/body_json, and the same extraction semantics are shared by all three new capabilities.keyis the header name forheaderand a native gjson path forbody_json(for exampledata.uid,friends.1.first).success_conditionis a flat list whose entries are ANDed implicitly.opsupportseq/ne/in/not_in/exists/not_exists/gt/lt. When it is absent, behavior is equivalent to today's "200 means allow".success_conditionis evaluated after the existingstatusCode == 200check. When it is not satisfied, the request is rejected with the existingstatus_on_error; no new rejection semantics are introduced.mapped_upstream_headerscoexists with the existingallowed_upstream_headersand 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_fieldsunset) 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_fieldslets 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_authheaders are no longer folded in automatically. This is for callers whose credential is per-request-unique (a signed or timestampedAuthorization), where the default key would make every entry unique so the cache never hits. The trade-off is a responsibility shift: oncekey_fieldsis 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 byto_headeron 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.mdupdates.
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. Perdocs/developers/immutable-plugin-releases.md, plugin versions are proposed by the managed release preparation PR, so this PR leavesVERSIONuntouched.
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 throughsuccess_condition.SPEC-4675002: value extraction and renamed forwarding throughmapped_upstream_headers.SPEC-4675003: auth result caching and fail-open degradation throughcache.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:
- "A 200 from the authorization service means allow."
success_conditionlets 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. - "Authorization-derived upstream headers only come from same-named response headers." With
mapped_upstream_headersthe upstream may receive headers sourced from the response body and under a different name. This happens only when explicitly configured, andto_headeris always supplied by the user — the plugin never synthesizes a header name. - "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 optionalkey_fields, covering every decision input becomes the admin's responsibility). - "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.
- "The authorization response body is only used on
with_request_bodyrelated 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 ofmax_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 withstatus_on_error; non-200 → the existing error handling path is unaffected.mapped_upstream_headers: a value read from body / header / status is injected upstream underto_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 orwith_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_bodydisables caching altogether. The optionalkey_fieldsnarrows 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 oncluster-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.
Source: higress-group/higress