Skip to content

Commit 7a2bcfe

Browse files
committed
Merge branch 'main' into feature/s3-url-signing
2 parents 4ea48ae + 57db5a2 commit 7a2bcfe

File tree

11 files changed

+196
-214
lines changed

11 files changed

+196
-214
lines changed

README.md

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ STAC Auth Proxy is a proxy API that mediates between the client and an internall
1919
## Usage
2020

2121
> [!NOTE]
22-
> Currently, the project can only be installed by downloading the repository. It will eventually be available on Docker ([#5](https://github.com/developmentseed/issues/5)) and PyPi ([#30](https://github.com/developmentseed/issues/30)).
22+
> Currently, the project can only be installed by downloading the repository. It will eventually be available on Docker ([#5](https://github.com/developmentseed/stac-auth-proxy/issues/5)) and PyPi ([#30](https://github.com/developmentseed/stac-auth-proxy/issues/30)).
2323
2424
### Installation
2525

@@ -125,7 +125,7 @@ The application is configurable via environment variables.
125125
- **Default:**
126126
```json
127127
{
128-
"^/search$": ["POST"],
128+
"^/search$": ["GET", "POST"],
129129
"^/collections/([^/]+)/items$": ["GET", "POST"]
130130
}
131131
```
@@ -138,7 +138,7 @@ While this project aims to provide utility out-of-the-box as a runnable applicat
138138

139139
### Middleware Stack
140140

141-
The middleware stack is processed in reverse order (bottom to top):
141+
Requests pass through a chain of middleware, each performing individual tasks:
142142

143143
1. **EnforceAuthMiddleware**
144144

@@ -156,8 +156,8 @@ The middleware stack is processed in reverse order (bottom to top):
156156

157157
- Retrieves [CQL2 expression](http://developmentseed.org/cql2-rs/latest/python/#cql2.Expr) from request state
158158
- Augments request with CQL2 filter:
159-
- Modifies query strings for GET requests
160-
- Modifies JSON bodies for POST/PUT/PATCH requests
159+
- Modifies query strings for `GET` requests
160+
- Modifies JSON bodies for `POST`/`PUT`/`PATCH` requests
161161

162162
4. **OpenApiMiddleware**
163163

@@ -173,10 +173,10 @@ The middleware stack is processed in reverse order (bottom to top):
173173
The system supports generating CQL2 filters based on request context to provide row-level content filtering. These CQL2 filters are then set on outgoing requests prior to the upstream API.
174174

175175
> [!IMPORTANT]
176-
> The upstream STAC API must support the [STAC API Filter Extension](https://github.com/stac-api-extensions/filter/blob/main/README.md).
176+
> The upstream STAC API must support the [STAC API Filter Extension](https://github.com/stac-api-extensions/filter/blob/main/README.md), including the [Features Filter](http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter) conformance class on to the Features resource (`/collections/{cid}/items`) [#37](https://github.com/developmentseed/stac-auth-proxy/issues/37).
177177

178178
> [!TIP]
179-
> Integration with external authorization systems (e.g. [Open Policy Agent](https://www.openpolicyagent.org/)) can be achieved by replacing the default `BuildCql2FilterMiddleware` with a custom async middleware that is capable of generating [`cql2.Expr` objects](https://developmentseed.org/cql2-rs/latest/python/#cql2.Expr).
179+
> Integration with external authorization systems (e.g. [Open Policy Agent](https://www.openpolicyagent.org/)) can be achieved by specifying an `ITEMS_FILTER` that points to a class/function that, once initialized, returns a [`cql2.Expr` object](https://developmentseed.org/cql2-rs/latest/python/#cql2.Expr) when called with the request context.
180180

181181
#### Example GET Request Flow
182182

@@ -196,13 +196,13 @@ sequenceDiagram
196196
| -------------------------------------------------------- | -------- | ---------------------------------------------- | ------ | ---------- | ------------------------------------------------------------------------------------------------------ |
197197
|| `POST` | `/search` | Read | Item | Append body with generated CQL2 query. |
198198
|| `GET` | `/search` | Read | Item | Append query params with generated CQL2 query. |
199-
| ❌ ([#22](https://github.com/developmentseed/issues/22)) | `POST` | `/collections/` | Create | Collection | Validate body with generated CQL2 query. |
200-
| ❌ ([#23](https://github.com/developmentseed/issues/23)) | `GET` | `/collections/{collection_id}` | Read | Collection | Append query params with generated CQL2 query. |
201-
| ❌ ([#22](https://github.com/developmentseed/issues/22)) | `PUT` | `/collections/{collection_id}}` | Update | Collection | Fetch Collection and validate CQL2 query; merge Item with body and validate with generated CQL2 query. |
202-
| ❌ ([#22](https://github.com/developmentseed/issues/22)) | `DELETE` | `/collections/{collection_id}` | Delete | Collection | Fetch Collectiion and validate with CQL2 query. |
199+
| ❌ ([#22](https://github.com/developmentseed/stac-auth-proxy/issues/22)) | `POST` | `/collections/` | Create | Collection | Validate body with generated CQL2 query. |
200+
| ❌ ([#23](https://github.com/developmentseed/stac-auth-proxy/issues/23)) | `GET` | `/collections/{collection_id}` | Read | Collection | Append query params with generated CQL2 query. |
201+
| ❌ ([#22](https://github.com/developmentseed/stac-auth-proxy/issues/22)) | `PUT` | `/collections/{collection_id}}` | Update | Collection | Fetch Collection and validate CQL2 query; merge Item with body and validate with generated CQL2 query. |
202+
| ❌ ([#22](https://github.com/developmentseed/stac-auth-proxy/issues/22)) | `DELETE` | `/collections/{collection_id}` | Delete | Collection | Fetch Collectiion and validate with CQL2 query. |
203203
|| `GET` | `/collections/{collection_id}/items` | Read | Item | Append query params with generated CQL2 query. |
204-
| ❌ ([#25](https://github.com/developmentseed/issues/25)) | `GET` | `/collections/{collection_id}/items/{item_id}` | Read | Item | Validate response against CQL2 query. |
205-
| ❌ ([#21](https://github.com/developmentseed/issues/21)) | `POST` | `/collections/{collection_id}/items` | Create | Item | Validate body with generated CQL2 query. |
206-
| ❌ ([#21](https://github.com/developmentseed/issues/21)) | `PUT` | `/collections/{collection_id}/items/{item_id}` | Update | Item | Fetch Item and validate CQL2 query; merge Item with body and validate with generated CQL2 query. |
207-
| ❌ ([#21](https://github.com/developmentseed/issues/21)) | `DELETE` | `/collections/{collection_id}/items/{item_id}` | Delete | Item | Fetch Item and validate with CQL2 query. |
208-
| ❌ ([#21](https://github.com/developmentseed/issues/21)) | `POST` | `/collections/{collection_id}/bulk_items` | Create | Item | Validate items in body with generated CQL2 query. |
204+
| ❌ ([#25](https://github.com/developmentseed/stac-auth-proxy/issues/25)) | `GET` | `/collections/{collection_id}/items/{item_id}` | Read | Item | Validate response against CQL2 query. |
205+
| ❌ ([#21](https://github.com/developmentseed/stac-auth-proxy/issues/21)) | `POST` | `/collections/{collection_id}/items` | Create | Item | Validate body with generated CQL2 query. |
206+
| ❌ ([#21](https://github.com/developmentseed/stac-auth-proxy/issues/21)) | `PUT` | `/collections/{collection_id}/items/{item_id}` | Update | Item | Fetch Item and validate CQL2 query; merge Item with body and validate with generated CQL2 query. |
207+
| ❌ ([#21](https://github.com/developmentseed/stac-auth-proxy/issues/21)) | `DELETE` | `/collections/{collection_id}/items/{item_id}` | Delete | Item | Fetch Item and validate with CQL2 query. |
208+
| ❌ ([#21](https://github.com/developmentseed/stac-auth-proxy/issues/21)) | `POST` | `/collections/{collection_id}/bulk_items` | Create | Item | Validate items in body with generated CQL2 query. |

docker-compose.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
services:
22
stac:
3-
image: ghcr.io/stac-utils/stac-fastapi-pgstac
3+
image: ghcr.io/stac-utils/stac-fastapi-pgstac:5.0.0
44
environment:
55
APP_HOST: 0.0.0.0
66
APP_PORT: 8001

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ classifiers = [
88
dependencies = [
99
"authlib>=1.3.2",
1010
"brotli>=1.1.0",
11-
"cql2>=0.3.4",
11+
"cql2>=0.3.5",
1212
"fastapi>=0.115.5",
1313
"httpx>=0.28.0",
1414
"jinja2>=3.1.4",

src/stac_auth_proxy/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class Settings(BaseSettings):
6969
# Filters
7070
items_filter: Optional[ClassInput] = None
7171
items_filter_endpoints: Optional[EndpointMethods] = {
72-
r"^/search$": ["POST"],
72+
r"^/search$": ["GET", "POST"],
7373
r"^/collections/([^/]+)/items$": ["GET", "POST"],
7474
}
7575

src/stac_auth_proxy/handlers/reverse_proxy.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ async def proxy_request(self, request: Request) -> httpx.Response:
4444
headers=headers,
4545
content=request.stream(),
4646
)
47+
48+
# NOTE: HTTPX adds headers, so we need to trim them before sending request
49+
for h in rp_req.headers:
50+
if h not in headers:
51+
del rp_req.headers[h]
52+
4753
logger.debug(f"Proxying request to {rp_req.url}")
4854

4955
start_time = time.perf_counter()

src/stac_auth_proxy/lifespan/ServerHealthCheck.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ class ServerHealthCheck:
1515
"""Health check for upstream API."""
1616

1717
url: str | HttpUrl
18-
max_retries: int = 5
19-
retry_delay: float = 0.25
20-
retry_delay_max: float = 10.0
18+
max_retries: int = 10
19+
retry_delay: float = 0.5
20+
retry_delay_max: float = 5.0
2121
timeout: float = 5.0
2222

2323
def __post_init__(self):

tests/test_authn.py

Lines changed: 0 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -112,79 +112,3 @@ def test_scopes(
112112
)
113113
expected_status_code = 200 if expected_permitted else 401
114114
assert response.status_code == expected_status_code
115-
116-
117-
# @pytest.mark.parametrize(
118-
# "is_valid, path, method",
119-
# [
120-
# *[
121-
# [True, *endpoint_method]
122-
# for endpoint_method in [
123-
# ["/collections", "POST"],
124-
# ["/collections/foo", "PUT"],
125-
# ["/collections/foo", "PATCH"],
126-
# ["/collections/foo/items", "POST"],
127-
# ["/collections/foo/items/bar", "PUT"],
128-
# ["/collections/foo/items/bar", "PATCH"],
129-
# ]
130-
# ],
131-
# *[
132-
# [False, *endpoint_method]
133-
# for endpoint_method in [
134-
# ["/collections/foo", "DELETE"],
135-
# ["/collections/foo/items/bar", "DELETE"],
136-
# ]
137-
# ],
138-
# ],
139-
# )
140-
# def test_scopes(source_api_server, token_builder, is_valid, path, method):
141-
# """Private endpoints permit access with a valid token."""
142-
# test_app = app_factory(
143-
# upstream_url=source_api_server,
144-
# default_public=True,
145-
# private_endpoints={
146-
# r"^/collections$": [
147-
# ("POST", ["collections:create"]),
148-
# ],
149-
# r"^/collections/([^/]+)$": [
150-
# # ("PUT", ["collections:update"]),
151-
# # ("PATCH", ["collections:update"]),
152-
# ("DELETE", ["collections:delete"]),
153-
# ],
154-
# r"^/collections/([^/]+)/items$": [
155-
# ("POST", ["items:create"]),
156-
# ],
157-
# r"^/collections/([^/]+)/items/([^/]+)$": [
158-
# # ("PUT", ["items:update"]),
159-
# # ("PATCH", ["items:update"]),
160-
# ("DELETE", ["items:delete"]),
161-
# ],
162-
# r"^/collections/([^/]+)/bulk_items$": [
163-
# ("POST", ["items:create"]),
164-
# ],
165-
# },
166-
# )
167-
# valid_auth_token = token_builder(
168-
# {
169-
# "scopes": " ".join(
170-
# [
171-
# "collection:create",
172-
# "items:create",
173-
# "collections:update",
174-
# "items:update",
175-
# ]
176-
# )
177-
# }
178-
# )
179-
# client = TestClient(test_app)
180-
181-
# response = client.request(
182-
# method=method,
183-
# url=path,
184-
# headers={"Authorization": f"Bearer {valid_auth_token}"},
185-
# json={} if method != "DELETE" else None,
186-
# )
187-
# if is_valid:
188-
# assert response.status_code == 200
189-
# else:
190-
# assert response.status_code == 403

tests/test_filters_jinja2.py

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
"""Tests for Jinja2 CQL2 filter (simplified for readability)."""
22

33
import json
4-
from typing import cast
5-
from unittest.mock import MagicMock
64

75
import cql2
86
import pytest
97
from fastapi.testclient import TestClient
10-
from httpx import Request
11-
from utils import AppFactory, parse_query_string
8+
from utils import AppFactory, get_upstream_request
129

1310
FILTER_EXPR_CASES = [
1411
pytest.param(
@@ -148,14 +145,6 @@ def _build_client(
148145
return TestClient(app, headers=headers)
149146

150147

151-
async def _get_upstream_request(mock_upstream: MagicMock):
152-
"""Fetch the raw body and query params from the single upstream request."""
153-
assert mock_upstream.call_count == 1
154-
[request] = cast(list[Request], mock_upstream.call_args[0])
155-
req_body = request._streamed_body
156-
return req_body.decode(), parse_query_string(request.url.query.decode("utf-8"))
157-
158-
159148
@pytest.mark.parametrize(
160149
"filter_template_expr, expected_auth_filter, expected_anon_filter",
161150
FILTER_EXPR_CASES,
@@ -182,8 +171,8 @@ async def test_search_post(
182171
response.raise_for_status()
183172

184173
# Retrieve the JSON body that was actually sent upstream
185-
proxied_body_str = (await _get_upstream_request(mock_upstream))[0]
186-
proxied_body = json.loads(proxied_body_str)
174+
proxied_request = await get_upstream_request(mock_upstream)
175+
proxied_body = json.loads(proxied_request.body)
187176

188177
# Determine the expected combined filter
189178
proxy_filter = cql2.Expr(
@@ -231,8 +220,8 @@ async def test_search_get(
231220
response.raise_for_status()
232221

233222
# For GET, we expect the upstream body to be empty, but URL params to be appended
234-
proxied_body, upstream_query = await _get_upstream_request(mock_upstream)
235-
assert proxied_body == ""
223+
proxied_request = await get_upstream_request(mock_upstream)
224+
assert proxied_request.body == ""
236225

237226
# Determine the expected combined filter
238227
proxy_filter = cql2.Expr(
@@ -253,7 +242,7 @@ async def test_search_get(
253242
"filter-lang": filter_lang,
254243
}
255244
assert (
256-
upstream_query == expected_output
245+
proxied_request.query_params == expected_output
257246
), "GET query should combine filter expressions."
258247

259248

@@ -284,15 +273,15 @@ async def test_items_list(
284273
response.raise_for_status()
285274

286275
# For GET items, we also expect an empty body and appended querystring
287-
proxied_body, proxied_query = await _get_upstream_request(mock_upstream)
288-
assert proxied_body == ""
276+
proxied_request = await get_upstream_request(mock_upstream)
277+
assert proxied_request.body == ""
289278

290279
# Only the appended filter (no input_filter merges in these particular tests),
291280
# but you could do similar merging logic if needed.
292281
proxy_filter = cql2.Expr(
293282
expected_auth_filter if is_authenticated else expected_anon_filter
294283
)
295-
assert proxied_query == {
284+
assert proxied_request.query_params == {
296285
"filter-lang": "cql2-text",
297286
"filter": (
298287
proxy_filter + cql2.Expr(qs_filter)

tests/test_proxy.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Test authentication cases for the proxy app."""
2+
3+
from fastapi.testclient import TestClient
4+
from utils import AppFactory, get_upstream_request
5+
6+
app_factory = AppFactory(
7+
oidc_discovery_url="https://example-stac-api.com/.well-known/openid-configuration",
8+
default_public=True,
9+
public_endpoints={},
10+
private_endpoints={},
11+
)
12+
13+
14+
async def test_proxied_headers_no_encoding(source_api_server, mock_upstream):
15+
"""Clients that don't accept encoding should not receive it."""
16+
test_app = app_factory(upstream_url=source_api_server)
17+
18+
client = TestClient(test_app)
19+
req = client.build_request(method="GET", url="/", headers={})
20+
for h in req.headers:
21+
if h in ["accept-encoding"]:
22+
del req.headers[h]
23+
client.send(req)
24+
25+
proxied_request = await get_upstream_request(mock_upstream)
26+
assert "accept-encoding" not in proxied_request.headers
27+
28+
29+
async def test_proxied_headers_with_encoding(source_api_server, mock_upstream):
30+
"""Clients that do accept encoding should receive it."""
31+
test_app = app_factory(upstream_url=source_api_server)
32+
33+
client = TestClient(test_app)
34+
req = client.build_request(
35+
method="GET", url="/", headers={"accept-encoding": "gzip"}
36+
)
37+
client.send(req)
38+
39+
proxied_request = await get_upstream_request(mock_upstream)
40+
assert proxied_request.headers.get("accept-encoding") == "gzip"

tests/utils.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import json
44
from dataclasses import dataclass
5-
from typing import Callable
5+
from typing import Callable, cast
6+
from unittest.mock import MagicMock
67
from urllib.parse import parse_qs, unquote
78

89
import httpx
10+
from httpx import Headers, Request
911

1012
from stac_auth_proxy import Settings, create_app
1113

@@ -68,3 +70,24 @@ def parse_query_string(qs: str) -> dict:
6870
result[key] = unquote(value)
6971

7072
return result
73+
74+
75+
async def get_upstream_request(mock_upstream: MagicMock) -> "UpstreamRequest":
76+
"""Fetch the raw body and query params from the single upstream request."""
77+
assert mock_upstream.call_count == 1
78+
[request] = cast(list[Request], mock_upstream.call_args[0])
79+
req_body = request._streamed_body
80+
return UpstreamRequest(
81+
body=req_body.decode(),
82+
query_params=parse_query_string(request.url.query.decode("utf-8")),
83+
headers=request.headers,
84+
)
85+
86+
87+
@dataclass
88+
class UpstreamRequest:
89+
"""The raw body and query params from the single upstream request."""
90+
91+
body: str
92+
query_params: dict
93+
headers: Headers

0 commit comments

Comments
 (0)