Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 62 additions & 4 deletions docs/gateway/policies/writing-custom-python-policies.md
Original file line number Diff line number Diff line change
Expand Up @@ -570,10 +570,12 @@ Available in `on_request_body()`.

| Field | Type | Description |
|-------|------|-------------|
| `headers` | `Headers` | Read-only, case-insensitive request headers |
| `headers` | `Headers` | Read-only, case-insensitive request headers (**current** — may reflect mutations by earlier header-phase policies) |
| `body` | `Body \| None` | Buffered request body (`.content: bytes`, `.present: bool`) |
| `path` | `str` | Request path (e.g., `/api/v1/items`) |
| `method` | `str` | HTTP method (e.g., `POST`) |
| `downstream` | `DownstreamContext \| None` | Snapshot of the **original** client request (`.request.headers: Headers`). `None` on older gateways — see [Validating original headers](#validating-original-headers-downstream--upstream) |
| `upstream` | `UpstreamRequestContext \| None` | Resolved upstream target for this request (`.name: str`, `.url: str`, `.base_path: str`). `None` on older gateways |
| `shared` | `SharedContext` | API metadata, auth context, and cross-policy metadata bag |

### `RequestHeaderContext`
Expand All @@ -582,11 +584,13 @@ Available in `on_request_headers()`.

| Field | Type | Description |
|-------|------|-------------|
| `headers` | `Headers` | Read-only, case-insensitive request headers |
| `headers` | `Headers` | Read-only, case-insensitive request headers (**current**) |
| `path` | `str` | Request path |
| `method` | `str` | HTTP method |
| `authority` | `str` | Request authority |
| `scheme` | `str` | Request scheme (`http`/`https`) |
| `downstream` | `DownstreamContext \| None` | Snapshot of the **original** client request (`.request.headers: Headers`). `None` on older gateways |
| `upstream` | `UpstreamRequestContext \| None` | Resolved upstream target for this request (`.name: str`, `.url: str`, `.base_path: str`). `None` on older gateways |
| `shared` | `SharedContext` | API metadata, auth context, and cross-policy metadata bag |

### `ResponseContext`
Expand All @@ -599,9 +603,11 @@ Available in `on_response_body()`.
| `request_body` | `Body \| None` | Original request body |
| `request_path` | `str` | Original request path |
| `request_method` | `str` | Original HTTP method |
| `response_headers` | `Headers` | Response headers from upstream |
| `response_headers` | `Headers` | Response headers (**current** — may reflect mutations by earlier header-phase policies) |
| `response_body` | `Body \| None` | Buffered response body |
| `response_status` | `int` | HTTP status code from upstream |
| `downstream` | `DownstreamContext \| None` | Snapshot of the **original** client request (`.request.headers: Headers`). `None` on older gateways |
| `upstream` | `UpstreamResponseContext \| None` | Resolved upstream target plus a snapshot of the **original** upstream response (`.name: str`, `.url: str`, `.base_path: str`, `.response.headers: Headers`). `None` on older gateways |
| `shared` | `SharedContext` | API metadata, auth context, and cross-policy metadata bag |

### `ResponseHeaderContext`
Expand All @@ -611,8 +617,10 @@ Available in `on_response_headers()`.
| Field | Type | Description |
|-------|------|-------------|
| `request_headers` | `Headers` | Original request headers |
| `response_headers` | `Headers` | Response headers from upstream |
| `response_headers` | `Headers` | Response headers (**current**) |
| `response_status` | `int` | HTTP status code from upstream |
| `downstream` | `DownstreamContext \| None` | Snapshot of the **original** client request (`.request.headers: Headers`). `None` on older gateways |
| `upstream` | `UpstreamResponseContext \| None` | Resolved upstream target plus a snapshot of the **original** upstream response (`.name: str`, `.url: str`, `.base_path: str`, `.response.headers: Headers`). `None` on older gateways |
| `shared` | `SharedContext` | API metadata, auth context, and cross-policy metadata bag |

### `SharedContext`
Expand Down Expand Up @@ -642,6 +650,56 @@ Read-only, case-insensitive header wrapper.
| `get_all()` | `dict[str, list[str]]` | Defensive copy of all headers |
| `iterate()` | `Iterator[tuple[str, list[str]]]` | Iterate over `(name, values)` pairs |

### Validating original headers (`downstream` / `upstream`)

The gateway runs **every** policy's header phase before **any** policy's body
phase, and header mutations are applied in place. So if a later policy rewrites
a header during its header phase, a body-phase validator reading `headers` will
see the *rewritten* value — not what the client (or backend) actually sent.

When your policy needs to validate against the **original** value, read from the
snapshot instead:

- `ctx.downstream.request.headers` — the original **client request** headers.
- `ctx.upstream.response.headers` — the original **upstream response** headers
(response-phase contexts only).

These snapshots are captured before any policy mutation.

**Always nil-check and fall back.** `downstream`/`upstream` — and their nested
`request`/`response` — are `None` on older gateways that predate this feature. A
`None` snapshot means "not available", so fall back to the mutable headers so
your policy keeps working across gateway versions:

```python
def on_request_body(self, ctx: RequestContext, params: dict) -> RequestAction | None:
# Prefer the pristine client headers; fall back on older gateways.
headers = ctx.headers
if (
ctx.downstream is not None
and ctx.downstream.request is not None
and ctx.downstream.request.headers is not None
):
headers = ctx.downstream.request.headers

token = headers.get("authorization") # exactly what the client sent
if not token or not self._valid(token[0]):
return ImmediateResponse(
status_code=401,
headers={"content-type": "application/json"},
body=b'{"error":"unauthorized","message":"Invalid or expired credentials."}',
)
return None
```

For a response-phase policy validating a header the **backend** set, use
`ctx.upstream.response.headers` with the same nil-check pattern (guarding
`ctx.upstream`, `ctx.upstream.response`, then `.headers`).

> **Note:** `headers` and `downstream.request.headers` are identical unless
> another policy actually mutated the header, so test the mutate-then-validate
> ordering explicitly — not just the happy path.

---

## Step 4: Register and Build
Expand Down
50 changes: 50 additions & 0 deletions gateway/gateway-runtime/api/proto/python_executor.proto
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,44 @@ message StreamBody {
uint64 index = 3;
}

// DownstreamRequest carries a snapshot of the request as received from the
// downstream client, captured before any policy mutation.
message DownstreamRequest {
Headers headers = 1;
}

// DownstreamContext identifies the downstream client and carries a snapshot of
// the client request. Absent on older gateways — new policies must treat an
// unset field as "not available" and fall back to legacy validation against
// the mutable headers.
message DownstreamContext {
DownstreamRequest request = 1;
}

// UpstreamRequestContext identifies the route's resolved upstream target during
// the request phase. Absent on older gateways.
message UpstreamRequestContext {
string name = 1;
string url = 2;
string base_path = 3;
}

// UpstreamResponse carries a snapshot of the upstream response headers,
// captured before any policy mutation.
message UpstreamResponse {
Headers headers = 1;
}

// UpstreamResponseContext identifies the route's resolved upstream target during
// the response phase and carries a snapshot of the upstream response. Absent on
// older gateways — see DownstreamContext for the backward-compat contract.
message UpstreamResponseContext {
string name = 1;
string url = 2;
string base_path = 3;
UpstreamResponse response = 4;
}

message AuthContext {
bool authenticated = 1;
bool authorized = 2;
Expand All @@ -213,6 +251,8 @@ message RequestHeaderContext {
string authority = 4;
string scheme = 5;
string vhost = 6;
DownstreamContext downstream = 7;
UpstreamRequestContext upstream = 8;
}

message RequestContext {
Expand All @@ -223,6 +263,8 @@ message RequestContext {
string authority = 5;
string scheme = 6;
string vhost = 7;
DownstreamContext downstream = 8;
UpstreamRequestContext upstream = 9;
}

message ResponseHeaderContext {
Expand All @@ -232,6 +274,8 @@ message ResponseHeaderContext {
string request_method = 4;
Headers response_headers = 5;
int32 response_status = 6;
DownstreamContext downstream = 7;
UpstreamResponseContext upstream = 8;
}

message ResponseContext {
Expand All @@ -242,6 +286,8 @@ message ResponseContext {
Headers response_headers = 5;
Body response_body = 6;
int32 response_status = 7;
DownstreamContext downstream = 8;
UpstreamResponseContext upstream = 9;
}

message RequestStreamContext {
Expand All @@ -251,6 +297,8 @@ message RequestStreamContext {
string authority = 4;
string scheme = 5;
string vhost = 6;
DownstreamContext downstream = 7;
UpstreamRequestContext upstream = 8;
}

message ResponseStreamContext {
Expand All @@ -260,6 +308,8 @@ message ResponseStreamContext {
string request_method = 4;
Headers response_headers = 5;
int32 response_status = 6;
DownstreamContext downstream = 7;
UpstreamResponseContext upstream = 8;
}

message RequestHeadersPayload {
Expand Down
2 changes: 1 addition & 1 deletion gateway/gateway-runtime/policy-engine/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
github.com/wso2/api-platform/common v0.0.0-20260326194347-3d85c50eae71
github.com/wso2/api-platform/sdk/core v0.2.18
github.com/wso2/api-platform/sdk/core v0.3.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
Expand Down
4 changes: 2 additions & 2 deletions gateway/gateway-runtime/policy-engine/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wso2/api-platform/sdk/core v0.2.18 h1:j6UhHjGBa9qCxqbT4p8cwLUKkQVtpvoMTMsjs1DfYR0=
github.com/wso2/api-platform/sdk/core v0.2.18/go.mod h1:NZMDIQadQDpbynlCLYdZT6duiHwnf7emr7SbLHdCjaE=
github.com/wso2/api-platform/sdk/core v0.3.0 h1:iPUEwB1lpjQto/Gjgl1F4ZrrU4P85bXvmabxgBSHs7E=
github.com/wso2/api-platform/sdk/core v0.3.0/go.mod h1:NZMDIQadQDpbynlCLYdZT6duiHwnf7emr7SbLHdCjaE=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
Expand Down
Loading
Loading