Skip to content

Commit 6f9216b

Browse files
committed
feat: add opa integration (#47)
closes #24
1 parent 8c66ba8 commit 6f9216b

File tree

16 files changed

+575
-9
lines changed

16 files changed

+575
-9
lines changed

README.md

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ STAC Auth Proxy is a proxy API that mediates between the client and your interna
99

1010
## ✨Features✨
1111

12-
- 🔐 Authentication: Selectively apply [OpenID Connect (OIDC)](https://openid.net/developers/how-connect-works/) auth*n token validation & optional scope requirements to some or all endpoints & methods
13-
- 🛂 Content Filtering: Apply CQL2 filters to client requests, utilizing the [Filter Extension](https://github.com/stac-api-extensions/filter?tab=readme-ov-file) to filter API content based on user context
14-
- 🧩 Authentication Extension: Integrate the [Authentication Extension](https://github.com/stac-extensions/authentication) into API responses
15-
- 📘 OpenAPI Augmentation: Update API's [OpenAPI document](https://swagger.io/specification/) with security requirements, keeping auto-generated docs/UIs accurate (e.g. [Swagger UI](https://swagger.io/tools/swagger-ui/))
16-
- 🗜️ Response compression: Compress API responses via [`starlette-cramjam`](https://github.com/developmentseed/starlette-cramjam/)
12+
- **🔐 Authentication:** Apply [OpenID Connect (OIDC)](https://openid.net/developers/how-connect-works/) token validation and optional scope checks to specified endpoints and methods
13+
- **🛂 Content Filtering:** Use CQL2 filters via the [Filter Extension](https://github.com/stac-api-extensions/filter?tab=readme-ov-file) to tailor API responses based on user context
14+
- **🤝 External Policy Integration:** Integrate with externalsystems (e.g. [Open Policy Agent (OPA)](https://www.openpolicyagent.org/)) to generate CQL2 filters dynamically from policy decisions
15+
- **🧩 Authentication Extension:** Add the [Authentication Extension](https://github.com/stac-extensions/authentication) to API responses to expose auth-related metadata
16+
- **📘 OpenAPI Augmentation:** Enhance the [OpenAPI spec](https://swagger.io/specification/) with security details to keep auto-generated docs and UIs (e.g., [Swagger UI](https://swagger.io/tools/swagger-ui/)) accurate
17+
- **🗜️ Response Compression:** Optimize response sizes using [`starlette-cramjam`](https://github.com/developmentseed/starlette-cramjam/)
1718

1819
## Usage
1920

@@ -185,9 +186,6 @@ The system supports generating CQL2 filters based on request context to provide
185186
> [!IMPORTANT]
186187
> 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].
187188

188-
> [!TIP]
189-
> 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.
190-
191189
#### Filters
192190

193191
If enabled, filters are intended to be applied to the following endpoints:
@@ -270,6 +268,108 @@ sequenceDiagram
270268
STAC API->>Client: Response
271269
```
272270

271+
#### Authoring Filter Generators
272+
273+
The `ITEMS_FILTER_CLS` configuration option can be used to specify a class that will be used to generate a CQL2 filter for the request. The class must define a `__call__` method that accepts a single argument: a dictionary containing the request context; and returns a valid `cql2-text` expression (as a `str`) or `cql2-json` expression (as a `dict`).
274+
275+
> [!TIP]
276+
> An example integration can be found in [`examples/custom-integration`](https://github.com/developmentseed/stac-auth-proxy/blob/main/examples/custom-integration).
277+
278+
##### Basic Filter Generator
279+
280+
```py
281+
import dataclasses
282+
from typing import Any
283+
284+
from cql2 import Expr
285+
286+
287+
@dataclasses.dataclass
288+
class ExampleFilter:
289+
async def __call__(self, context: dict[str, Any]) -> str:
290+
return "true"
291+
```
292+
293+
> [!TIP]
294+
> Despite being referred to as a _class_, a filter generator could be written as a function.
295+
>
296+
> <details>
297+
>
298+
> <summary>Example</summary>
299+
>
300+
> ```py
301+
> from typing import Any
302+
>
303+
> from cql2 import Expr
304+
>
305+
>
306+
> def example_filter():
307+
> async def example_filter(context: dict[str, Any]) -> str | dict[str, Any]:
308+
> return Expr("true")
309+
> return example_filter
310+
> ```
311+
>
312+
> </details>
313+
314+
##### Complex Filter Generator
315+
316+
An example of a more complex filter generator where the filter is generated based on the response of an external API:
317+
318+
```py
319+
import dataclasses
320+
from typing import Any
321+
322+
from httpx import AsyncClient
323+
from stac_auth_proxy.utils.cache import MemoryCache
324+
325+
326+
@dataclasses.dataclass
327+
class ApprovedCollectionsFilter:
328+
api_url: str
329+
kind: Literal["item", "collection"] = "item"
330+
client: AsyncClient = dataclasses.field(init=False)
331+
cache: MemoryCache = dataclasses.field(init=False)
332+
333+
def __post_init__(self):
334+
# We keep the client in the class instance to avoid creating a new client for
335+
# each request, taking advantage of the client's connection pooling.
336+
self.client = AsyncClient(base_url=self.api_url)
337+
self.cache = MemoryCache(ttl=30)
338+
339+
async def __call__(self, context: dict[str, Any]) -> dict[str, Any]:
340+
token = context["req"]["headers"].get("authorization")
341+
342+
try:
343+
# Check cache for a previously generated filter
344+
approved_collections = self.cache[token]
345+
except KeyError:
346+
# Lookup approved collections from an external API
347+
approved_collections = await self.lookup(token)
348+
self.cache[token] = approved_collections
349+
350+
# Build CQL2 filter
351+
return {
352+
"op": "a_containedby",
353+
"args": [
354+
{"property": "collection" if self.kind == "item" else "id"},
355+
approved_collections
356+
],
357+
}
358+
359+
async def lookup(self, token: Optional[str]) -> list[str]:
360+
# Lookup approved collections from an external API
361+
headers = {"Authorization": f"Bearer {token}"} if token else {}
362+
response = await self.client.get(
363+
f"/get-approved-collections",
364+
headers=headers,
365+
)
366+
response.raise_for_status()
367+
return response.json()["collections"]
368+
```
369+
370+
> [!TIP]
371+
> Filter generation runs for every relevant request. Consider memoizing external API calls to improve performance.
372+
273373
[^21]: https://github.com/developmentseed/stac-auth-proxy/issues/21
274374
[^22]: https://github.com/developmentseed/stac-auth-proxy/issues/22
275375
[^23]: https://github.com/developmentseed/stac-auth-proxy/issues/23
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
ARG STAC_AUTH_PROXY_VERSION
2+
FROM ghcr.io/developmentseed/stac-auth-proxy:${STAC_AUTH_PROXY_VERSION}
3+
4+
ADD . /opt/stac-auth-proxy-integration
5+
6+
RUN pip install /opt/stac-auth-proxy-integration

examples/custom-integration/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Custom Integration Example
2+
3+
This example demonstrates how to integrate with a custom filter generator.
4+
5+
## Running the Example
6+
7+
From the root directory, run:
8+
9+
```sh
10+
docker compose -f docker-compose.yaml -f examples/custom-integration/docker-compose.yaml up
11+
```
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# This compose file is intended to be run alongside the `docker-compose.yaml` file in the
2+
# root directory.
3+
4+
services:
5+
proxy:
6+
build:
7+
context: examples/custom-integration
8+
args:
9+
STAC_AUTH_PROXY_VERSION: 0.1.2
10+
environment:
11+
ITEMS_FILTER_CLS: custom_integration:cql2_builder
12+
ITEMS_FILTER_KWARGS: '{"admin_user": "user123"}'
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[project]
2+
name = "custom_integration"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
requires-python = ">=3.9"
7+
dependencies = []
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""
2+
A custom integration example.
3+
4+
In this example, we're intentionally using a functional pattern but you could also use a
5+
class like we do in the integrations found in stac_auth_proxy.filters.
6+
"""
7+
8+
from typing import Any
9+
10+
11+
def cql2_builder(admin_user: str):
12+
"""CQL2 builder integration filter."""
13+
# NOTE: This is where you would set up things like connection pools.
14+
# NOTE: args/kwargs are passed in via environment variables.
15+
16+
async def custom_integration_filter(ctx: dict[str, Any]) -> str:
17+
"""
18+
Generate CQL2 expressions based on the request context.
19+
20+
Returns a CQL2 expression, either as a string (cql2-text) or as a dict (cql2-json).
21+
"""
22+
# NOTE: This is where you would perform a lookup from a database, API, etc.
23+
# NOTE: ctx is the request context, which includes the payload, headers, etc.
24+
25+
if ctx["payload"] and ctx["payload"]["sub"] == admin_user:
26+
return "1=1"
27+
return "private = true"
28+
29+
return custom_integration_filter

examples/opa/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Open Policy Agent (OPA) Integration
2+
3+
This example demonstrates how to integrate with an Open Policy Agent (OPA) to authorize requests to a STAC API.
4+
5+
## Running the Example
6+
7+
From the root directory, run:
8+
9+
```sh
10+
docker compose -f docker-compose.yaml -f examples/opa/docker-compose.yaml up
11+
```
12+
13+
## Testing OPA
14+
15+
```sh
16+
▶ curl -X POST "http://localhost:8181/v1/data/stac/cql2" \
17+
-H "Content-Type: application/json" \
18+
-d '{"input":{"payload": null}}'
19+
{"result":"private = true"}
20+
```
21+
22+
```sh
23+
▶ curl -X POST "http://localhost:8181/v1/data/stac/cql2" \
24+
-H "Content-Type: application/json" \
25+
-d '{"input":{"payload": {"sub": "user1"}}}'
26+
{"result":"1=1"}
27+
```

examples/opa/docker-compose.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
services:
2+
proxy:
3+
environment:
4+
ITEMS_FILTER_CLS: stac_auth_proxy.filters:Opa
5+
ITEMS_FILTER_ARGS: '["http://opa:8181", "stac/cql2"]'
6+
7+
opa:
8+
image: openpolicyagent/opa:latest
9+
command: "run --server --addr=:8181 --watch /policies"
10+
ports:
11+
- "8181:8181"
12+
volumes:
13+
- ./examples/opa/policies:/policies
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package stac
2+
3+
default cql2 := "\"naip:year\" = 2021"
4+
5+
cql2 := "1=1" if {
6+
input.payload.sub != null
7+
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""CQL2 filter generators."""
22

3+
from .opa import Opa
34
from .template import Template
45

5-
__all__ = ["Template"]
6+
__all__ = [
7+
"Opa",
8+
"Template",
9+
]

src/stac_auth_proxy/filters/opa.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Integration with Open Policy Agent (OPA) to generate CQL2 filters for requests to a STAC API."""
2+
3+
from dataclasses import dataclass, field
4+
from typing import Any
5+
6+
import httpx
7+
8+
from ..utils.cache import MemoryCache, get_value_by_path
9+
10+
11+
@dataclass
12+
class Opa:
13+
"""Call Open Policy Agent (OPA) to generate CQL2 filters from request context."""
14+
15+
host: str
16+
decision: str
17+
18+
client: httpx.AsyncClient = field(init=False)
19+
cache: MemoryCache = field(init=False)
20+
cache_key: str = "req.headers.authorization"
21+
cache_ttl: float = 5.0
22+
23+
def __post_init__(self):
24+
"""Initialize the client."""
25+
self.client = httpx.AsyncClient(base_url=self.host)
26+
self.cache = MemoryCache(ttl=self.cache_ttl)
27+
28+
async def __call__(self, context: dict[str, Any]) -> str:
29+
"""Generate a CQL2 filter for the request."""
30+
token = get_value_by_path(context, self.cache_key)
31+
try:
32+
expr_str = self.cache[token]
33+
except KeyError:
34+
expr_str = await self._fetch(context)
35+
self.cache[token] = expr_str
36+
return expr_str
37+
38+
async def _fetch(self, context: dict[str, Any]) -> str:
39+
"""Fetch the CQL2 filter from OPA."""
40+
response = await self.client.post(
41+
f"/v1/data/{self.decision}",
42+
json={"input": context},
43+
)
44+
return response.raise_for_status().json()["result"]

0 commit comments

Comments
 (0)