Skip to content

Commit c3846ff

Browse files
committed
wip: structure opa example
1 parent 6f80325 commit c3846ff

File tree

9 files changed

+163
-3
lines changed

9 files changed

+163
-3
lines changed

README.md

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,6 @@ The system supports generating CQL2 filters based on request context to provide
178178
> [!IMPORTANT]
179179
> 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].
180180

181-
> [!TIP]
182-
> 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.
183-
184181
#### Filters
185182

186183
If enabled, filters are intended to be applied to the following endpoints:
@@ -263,6 +260,99 @@ sequenceDiagram
263260
STAC API->>Client: Response
264261
```
265262

263+
#### Authoring Filter Generators
264+
265+
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`).
266+
267+
> [!TIP]
268+
> An example of an Open Policy Agent (OPA) integration is available in the [examples/opa](examples/opa) directory, runnable with `docker compose -f docker-compose.yaml -f examples/opa/docker-compose.yaml up`.
269+
270+
##### Basic Filter Generator
271+
272+
```py
273+
import dataclasses
274+
from typing import Any
275+
276+
from cql2 import Expr
277+
278+
279+
@dataclasses.dataclass
280+
class ExampleFilter:
281+
async def __call__(self, context: dict[str, Any]) -> str:
282+
return "true"
283+
```
284+
285+
> [!TIP]
286+
> Despite being referred to as a _class_, a filter generator could be written as a function.
287+
>
288+
> <details>
289+
>
290+
> <summary>Example</summary>
291+
>
292+
> ```py
293+
> from typing import Any
294+
>
295+
> from cql2 import Expr
296+
>
297+
>
298+
> def example_filter():
299+
> def example_filter(context: dict[str, Any]) -> str | dict[str, Any]:
300+
> return Expr("true")
301+
> return example_filter
302+
> ```
303+
>
304+
> </details>
305+
306+
##### Complex Filter Generator
307+
308+
An example of a more complex filter generator where the filter is generated based on the response of an external API:
309+
310+
```py
311+
import dataclasses
312+
from typing import Any
313+
314+
from httpx import AsyncClient
315+
316+
317+
@dataclasses.dataclass
318+
class ApprovedCollectionsFilter:
319+
api_url: str
320+
kind: Literal["item", "collection"] = "item"
321+
client: AsyncClient = dataclasses.field(init=False)
322+
323+
def __post_init__(self):
324+
# We keep the client in the class instance to avoid creating a new client for
325+
# each request, taking advantage of the client's connection pooling.
326+
self.client = AsyncClient(base_url=self.api_url)
327+
328+
async def __call__(self, context: dict[str, Any]) -> dict[str, Any]:
329+
# Lookup approved collections from an external API
330+
token = context["req"]["headers"].get("Authorization")
331+
approved_collections = await self.lookup(token)
332+
333+
# Build CQL2 filter
334+
return {
335+
"op": "a_containedby",
336+
"args": [
337+
{"property": "collection" if self.kind == "item" else "id"},
338+
approved_collections
339+
],
340+
}
341+
342+
async def lookup(self, token: Optional[str]) -> list[str]:
343+
# Lookup approved collections from an external API
344+
headers = {"Authorization": f"Bearer {token}"} if token else {}
345+
response = await self.client.get(
346+
f"/get-approved-collections",
347+
headers=headers,
348+
)
349+
response.raise_for_status()
350+
return response.json()["collections"]
351+
```
352+
353+
> [!TIP]
354+
> Filter generation runs for every relevant request. Consider memoizing external API calls to improve performance.
355+
266356
[^21]: https://github.com/developmentseed/stac-auth-proxy/issues/21
267357
[^22]: https://github.com/developmentseed/stac-auth-proxy/issues/22
268358
[^23]: https://github.com/developmentseed/stac-auth-proxy/issues/23

examples/opa/.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

examples/opa/Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
FROM ghcr.io/developmentseed/stac-auth-proxy:latest
2+
3+
ADD . /opa
4+
5+
RUN pip install /opa

examples/opa/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
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+
```
10+
docker compose -f docker-compose.yaml -f examples/opa/docker-compose.yaml up
11+
```

examples/opa/docker-compose.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
services:
2+
proxy:
3+
depends_on:
4+
- stac
5+
build:
6+
context: examples/opa
7+
environment:
8+
UPSTREAM_URL: ${UPSTREAM_URL:-http://stac:8001}
9+
OIDC_DISCOVERY_URL: ${OIDC_DISCOVERY_URL:-http://localhost:8888/.well-known/openid-configuration}
10+
OIDC_DISCOVERY_INTERNAL_URL: ${OIDC_DISCOVERY_INTERNAL_URL:-http://oidc:8888/.well-known/openid-configuration}
11+
ITEMS_FILTER_CLS: opa_integration:OpaIntegration
12+
ITEMS_FILTER_ARGS: "[]"
13+
env_file:
14+
- path: .env
15+
required: false
16+
ports:
17+
- "8000:8000"
18+
volumes:
19+
- ./src:/app/src

examples/opa/opa_integration.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Integration with Open Policy Agent (OPA) to generate CQL2 filters for requests to a STAC API."""
2+
3+
import dataclasses
4+
from typing import Any
5+
6+
7+
@dataclasses.dataclass
8+
class OpaIntegration:
9+
"""Integration with Open Policy Agent (OPA) to generate CQL2 filters for requests to a STAC API."""
10+
11+
async def __call__(self, context: dict[str, Any]) -> str:
12+
"""Generate a CQL2 filter for the request."""
13+
return "(1=1)"

examples/opa/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[project]
2+
name = "opa_integration"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
requires-python = ">=3.9"
7+
dependencies = []

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,6 @@ dev = [
5353
[tool.pytest.ini_options]
5454
asyncio_default_fixture_loop_scope = "function"
5555
asyncio_mode = "auto"
56+
57+
[tool.uv.workspace]
58+
members = ["examples/opa"]

uv.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)