Skip to content

Commit c530c60

Browse files
committed
Merge branch 'main' into examples/oidc-docker-compose
2 parents 58c73ee + 57db5a2 commit c530c60

File tree

16 files changed

+446
-284
lines changed

16 files changed

+446
-284
lines changed

README.md

Lines changed: 187 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,208 @@
1-
# STAC Auth Proxy
1+
<div align="center">
2+
<h1 style="font-family: monospace">stac auth proxy</h1>
3+
<p align="center">Reverse proxy to apply auth*n to STAC APIs.</p>
4+
</div>
5+
6+
---
27

38
> [!WARNING]
49
> This project is currently in active development and may change drastically in the near future while we work towards solidifying a first release.
510
6-
STAC Auth Proxy is a proxy API that mediates between the client and and some internally accessible STAC API in order to provide a flexible authentication mechanism.
11+
STAC Auth Proxy is a proxy API that mediates between the client and an internally accessible STAC API to provide a flexible authentication, authorization, and content-filtering mechanism.
712

813
## Features
914

10-
- 🔐 Selectively apply OIDC auth to some or all endpoints & methods
11-
- 📖 Augments [OpenAPI](https://swagger.io/specification/) with auth information, keeping auto-generated docs (e.g. [Swagger UI](https://swagger.io/tools/swagger-ui/)) accurate
15+
- 🔐 Authentication: Selectively apply OIDC auth to some or all endpoints & methods
16+
- 🎟️ Content Filtering: Apply CQL2 filters to client requests, filtering API content based on user context
17+
- 📖 OpenAPI Augmentation: Update [OpenAPI](https://swagger.io/specification/) with security requirements, keeping auto-generated docs/UIs accurate (e.g. [Swagger UI](https://swagger.io/tools/swagger-ui/))
1218

13-
### CQL2 Filters
19+
## Usage
1420

15-
| Method | Endpoint | Action | Filter | Strategy |
16-
| -------- | ---------------------------------------------- | ------ | ------ | ---------------------------------------------------------------------------------------------------------- |
17-
| `POST` | `/search` | Read | Item | Append body with generated CQL2 query. |
18-
| `GET` | `/search` | Read | Item | Append query params with generated CQL2 query. |
19-
| `GET` | `/collections/{collection_id}/items` | Read | Item | Append query params with generated CQL2 query. |
20-
| `POST` | `/collections/{collection_id}/items` | Create | Item | Validate body with generated CQL2 query. |
21-
| `PUT` | `/collections/{collection_id}/items/{item_id}` | Update | Item | Fetch STAC Item and validate CQL2 query; merge STAC Item with body and validate with generated CQL2 query. |
22-
| `DELETE` | `/collections/{collection_id}/items/{item_id}` | Delete | Item | Fetch STAC Item and validate with CQL2 query. |
21+
> [!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/stac-auth-proxy/issues/5)) and PyPi ([#30](https://github.com/developmentseed/stac-auth-proxy/issues/30)).
2323
24-
#### Recipes
24+
### Installation
2525

26-
Only return collections that are mentioned in a `collections` array encoded within the auth token.
26+
For local development, we use [`uv`](https://docs.astral.sh/uv/) to manage project dependencies and environment.
2727

28+
```sh
29+
uv sync
2830
```
29-
"A_CONTAINEDBY(id, ('{{ token.collections | join(\"', '\") }}' ))"
31+
32+
Otherwise, the application can be installed as a standard Python module:
33+
34+
```sh
35+
pip install -e .
3036
```
3137

32-
## Installation
38+
### Running
3339

34-
Set up connection to upstream STAC API and the OpenID Connect provider by setting the following environment variables:
40+
The simplest way to run the project is by calling the module directly:
3541

36-
```bash
37-
export STAC_AUTH_PROXY_UPSTREAM_URL="https://some.url"
38-
export STAC_AUTH_PROXY_OIDC_DISCOVERY_URL="https://your-openid-connect-provider.com/.well-known/openid-configuration"
42+
```sh
43+
python -m stac_auth_proxy
3944
```
4045

41-
Install software:
46+
Alternatively, the application's factory can be passed to Uvicorn:
4247

43-
```bash
44-
uv run python -m stac_auth_proxy
48+
```sh
49+
uvicorn --factory stac_auth_proxy:create_app
4550
```
51+
52+
### Configuration
53+
54+
The application is configurable via environment variables.
55+
56+
- `UPSTREAM_URL`
57+
- The STAC API to proxy requests to
58+
- **Type:** HTTP(S) URL
59+
- **Required:** Yes
60+
- **Example:** `https://your-stac-api.com/stac`
61+
- `OIDC_DISCOVERY_URL`
62+
- OpenID Connect discovery document URL
63+
- **Type:** HTTP(S) URL
64+
- **Required:** Yes
65+
- **Example:** `https://auth.example.com/.well-known/openid-configuration`
66+
- `OIDC_DISCOVERY_INTERNAL_URL`
67+
- The internal network OpenID Connect discovery document URL
68+
- **Type:** HTTP(S) URL
69+
- **Required:** No, defaults to the value of `OIDC_DISCOVERY_URL`
70+
- **Example:** `http://auth/.well-known/openid-configuration`
71+
- `DEFAULT_PUBLIC`
72+
- **Description:** Default access policy for endpoints
73+
- **Type:** boolean
74+
- **Default:** `false`
75+
- **Example:** `false`, `1`, `True`
76+
- `PRIVATE_ENDPOINTS`
77+
- **Description:** Endpoints explicitly marked as requiring authentication, for use when `DEFAULT_PUBLIC == True`
78+
- **Type:** JSON object mapping regex patterns to HTTP methods OR tuples of HTTP methods and an array of strings representing required scopes
79+
- **Default:**
80+
```json
81+
{
82+
"^/collections$": ["POST"],
83+
"^/collections/([^/]+)$": ["PUT", "PATCH", "DELETE"],
84+
"^/collections/([^/]+)/items$": ["POST"],
85+
"^/collections/([^/]+)/items/([^/]+)$": ["PUT", "PATCH", "DELETE"],
86+
"^/collections/([^/]+)/bulk_items$": ["POST"]
87+
}
88+
```
89+
- `PUBLIC_ENDPOINTS`
90+
- **Description:** Endpoints explicitly marked as not requiring authentication, for use when `DEFAULT_PUBLIC == False`
91+
- **Type:** JSON object mapping regex patterns to HTTP methods
92+
- **Default:**
93+
```json
94+
{
95+
"^/api.html$": ["GET"],
96+
"^/api$": ["GET"]
97+
}
98+
```
99+
- `OPENAPI_SPEC_ENDPOINT`
100+
- Path to serve OpenAPI specification
101+
- **Type:** string or null
102+
- **Default:** `null` (disabled)
103+
- **Example:** `/api`
104+
- `ITEMS_FILTER`
105+
- Configuration for item-level filtering
106+
- **Type:** JSON object with class configuration
107+
- **Default:** `null`
108+
- Components:
109+
- `cls`: Python import path
110+
- `args`: List of positional arguments
111+
- `kwargs`: Dictionary of keyword arguments
112+
- **Example:**
113+
```json
114+
{
115+
"cls": "my_package.filters.OrganizationFilter",
116+
"args": ["org1"],
117+
"kwargs": {
118+
"field_name": "properties.organization"
119+
}
120+
}
121+
```
122+
- `ITEMS_FILTER_ENDPOINTS`
123+
- Where to apply item filtering
124+
- **Type:** JSON object mapping regex patterns to HTTP methods
125+
- **Default:**
126+
```json
127+
{
128+
"^/search$": ["GET", "POST"],
129+
"^/collections/([^/]+)/items$": ["GET", "POST"]
130+
}
131+
```
132+
133+
### Customization
134+
135+
While this project aims to provide utility out-of-the-box as a runnable application, it's likely won't address every project's needs. In these situations, this codebase can instead be treated as a library of components that can be used to augment any webserver that makes use of the [ASGI protocol](https://asgi.readthedocs.io/en/latest/) (e.g. [Django](https://docs.djangoproject.com/en/3.0/topics/async/), [Falcon](https://falconframework.org/), [FastAPI](https://github.com/tiangolo/fastapi), [Litestar](https://litestar.dev/), [Responder](https://responder.readthedocs.io/en/latest/), [Sanic](https://sanic.dev/), [Starlette](https://www.starlette.io/)). Review [`app.py`](https://github.com/developmentseed/stac-auth-proxy/blob/main/src/stac_auth_proxy/app.py) to get a sense of how we make use of the various components to construct a FastAPI application.
136+
137+
## Architecture
138+
139+
### Middleware Stack
140+
141+
Requests pass through a chain of middleware, each performing individual tasks:
142+
143+
1. **EnforceAuthMiddleware**
144+
145+
- Handles authentication and authorization
146+
- Configurable public/private endpoints
147+
- OIDC integration
148+
- Places auth token payload in request state
149+
150+
2. **BuildCql2FilterMiddleware**
151+
152+
- Builds CQL2 filters based on request context/state
153+
- Places [CQL2 expression](http://developmentseed.org/cql2-rs/latest/python/#cql2.Expr) in request state
154+
155+
3. **ApplyCql2FilterMiddleware**
156+
157+
- Retrieves [CQL2 expression](http://developmentseed.org/cql2-rs/latest/python/#cql2.Expr) from request state
158+
- Augments request with CQL2 filter:
159+
- Modifies query strings for `GET` requests
160+
- Modifies JSON bodies for `POST`/`PUT`/`PATCH` requests
161+
162+
4. **OpenApiMiddleware**
163+
164+
- Modifies OpenAPI specification based on endpoint configuration, adding security requirements
165+
- Only active if `openapi_spec_endpoint` is configured
166+
167+
5. **AddProcessTimeHeaderMiddleware**
168+
- Adds processing time headers
169+
- Useful for monitoring/debugging
170+
171+
### Data filtering via CQL2
172+
173+
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.
174+
175+
> [!IMPORTANT]
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).
177+
178+
> [!TIP]
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.
180+
181+
#### Example GET Request Flow
182+
183+
```mermaid
184+
sequenceDiagram
185+
Client->>Proxy: GET /collections
186+
Note over Proxy: EnforceAuth checks credentials
187+
Note over Proxy: BuildCql2Filter creates filter immediately
188+
Note over Proxy: ApplyCql2Filter modifies query string
189+
Proxy->>STAC API: GET /collection?filter=(collection=landsat)
190+
STAC API->>Client: Response
191+
```
192+
193+
#### Filters
194+
195+
| Supported | Method | Endpoint | Action | Filter | Strategy |
196+
| -------------------------------------------------------- | -------- | ---------------------------------------------- | ------ | ---------- | ------------------------------------------------------------------------------------------------------ |
197+
|| `POST` | `/search` | Read | Item | Append body with generated CQL2 query. |
198+
|| `GET` | `/search` | Read | Item | Append query params with generated 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. |
203+
|| `GET` | `/collections/{collection_id}/items` | Read | Item | Append query params 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/app.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,7 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
9090
public_endpoints=settings.public_endpoints,
9191
private_endpoints=settings.private_endpoints,
9292
default_public=settings.default_public,
93-
oidc_config_url=settings.oidc_discovery_url,
94-
oidc_config_internal_url=settings.oidc_discovery_internal_url,
93+
oidc_config_url=settings.oidc_discovery_internal_url,
9594
)
9695

9796
app.add_middleware(

src/stac_auth_proxy/config.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Configuration for the STAC Auth Proxy."""
22

33
import importlib
4-
from typing import Literal, Optional, Sequence, TypeAlias, Union
4+
from typing import Any, Literal, Optional, Sequence, TypeAlias, Union
55

6-
from pydantic import BaseModel, Field
6+
from pydantic import BaseModel, Field, model_validator
77
from pydantic.networks import HttpUrl
88
from pydantic_settings import BaseSettings, SettingsConfigDict
99

@@ -37,7 +37,7 @@ class Settings(BaseSettings):
3737
# External URLs
3838
upstream_url: HttpUrl
3939
oidc_discovery_url: HttpUrl
40-
oidc_discovery_internal_url: Optional[HttpUrl] = None
40+
oidc_discovery_internal_url: HttpUrl
4141

4242
wait_for_upstream: bool = True
4343

@@ -66,8 +66,16 @@ class Settings(BaseSettings):
6666
# Filters
6767
items_filter: Optional[ClassInput] = None
6868
items_filter_endpoints: Optional[EndpointMethods] = {
69-
r"^/search$": ["POST"],
69+
r"^/search$": ["GET", "POST"],
7070
r"^/collections/([^/]+)/items$": ["GET", "POST"],
7171
}
7272

7373
model_config = SettingsConfigDict()
74+
75+
@model_validator(mode="before")
76+
@classmethod
77+
def default_oidc_discovery_internal_url(cls, data: Any) -> Any:
78+
"""Set the internal OIDC discovery URL to the public URL if not set."""
79+
if not data.get("oidc_discovery_internal_url"):
80+
data["oidc_discovery_internal_url"] = data.get("oidc_discovery_url")
81+
return data

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):
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Middleware to apply CQL2 filters."""
2+
3+
import json
4+
from dataclasses import dataclass
5+
from logging import getLogger
6+
7+
from starlette.requests import Request
8+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
9+
10+
from ..utils import filters
11+
12+
logger = getLogger(__name__)
13+
14+
15+
@dataclass(frozen=True)
16+
class ApplyCql2FilterMiddleware:
17+
"""Middleware to apply the Cql2Filter to the request."""
18+
19+
app: ASGIApp
20+
21+
state_key: str = "cql2_filter"
22+
23+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
24+
"""Add the Cql2Filter to the request."""
25+
if scope["type"] != "http":
26+
return await self.app(scope, receive, send)
27+
28+
request = Request(scope)
29+
30+
if request.method == "GET":
31+
cql2_filter = getattr(request.state, self.state_key, None)
32+
if cql2_filter:
33+
# TODO: Differentiate between list/search and lookup
34+
scope["query_string"] = filters.append_qs_filter(
35+
request.url.query, cql2_filter
36+
)
37+
return await self.app(scope, receive, send)
38+
39+
elif request.method in ["POST", "PUT", "PATCH"]:
40+
41+
async def receive_and_apply_filter() -> Message:
42+
message = await receive()
43+
if message["type"] != "http.request":
44+
return message
45+
46+
cql2_filter = getattr(request.state, self.state_key, None)
47+
if cql2_filter:
48+
try:
49+
body = message.get("body", b"{}")
50+
except json.JSONDecodeError as e:
51+
logger.warning("Failed to parse request body as JSON")
52+
# TODO: Return a 400 error
53+
raise e
54+
55+
new_body = filters.append_body_filter(json.loads(body), cql2_filter)
56+
message["body"] = json.dumps(new_body).encode("utf-8")
57+
return message
58+
59+
return await self.app(scope, receive_and_apply_filter, send)
60+
61+
return await self.app(scope, receive, send)

0 commit comments

Comments
 (0)