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
116 changes: 108 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ STAC Auth Proxy is a proxy API that mediates between the client and your interna

## ✨Features✨

- 🔐 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
- 🛂 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
- 🧩 Authentication Extension: Integrate the [Authentication Extension](https://github.com/stac-extensions/authentication) into API responses
- 📘 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/))
- 🗜️ Response compression: Compress API responses via [`starlette-cramjam`](https://github.com/developmentseed/starlette-cramjam/)
- **🔐 Authentication:** Apply [OpenID Connect (OIDC)](https://openid.net/developers/how-connect-works/) token validation and optional scope checks to specified endpoints and methods
- **🛂 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
- **🤝 External Policy Integration:** Integrate with externalsystems (e.g. [Open Policy Agent (OPA)](https://www.openpolicyagent.org/)) to generate CQL2 filters dynamically from policy decisions
- **🧩 Authentication Extension:** Add the [Authentication Extension](https://github.com/stac-extensions/authentication) to API responses to expose auth-related metadata
- **📘 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
- **🗜️ Response Compression:** Optimize response sizes using [`starlette-cramjam`](https://github.com/developmentseed/starlette-cramjam/)

## Usage

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

> [!TIP]
> 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.

#### Filters

If enabled, filters are intended to be applied to the following endpoints:
Expand Down Expand Up @@ -270,6 +268,108 @@ sequenceDiagram
STAC API->>Client: Response
```

#### Authoring Filter Generators

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`).

> [!TIP]
> An example integration can be found in [`examples/custom-integration`](https://github.com/developmentseed/stac-auth-proxy/blob/main/examples/custom-integration).

##### Basic Filter Generator

```py
import dataclasses
from typing import Any

from cql2 import Expr


@dataclasses.dataclass
class ExampleFilter:
async def __call__(self, context: dict[str, Any]) -> str:
return "true"
```

> [!TIP]
> Despite being referred to as a _class_, a filter generator could be written as a function.
>
> <details>
>
> <summary>Example</summary>
>
> ```py
> from typing import Any
>
> from cql2 import Expr
>
>
> def example_filter():
> def example_filter(context: dict[str, Any]) -> str | dict[str, Any]:
> return Expr("true")
> return example_filter
> ```
>
> </details>

##### Complex Filter Generator

An example of a more complex filter generator where the filter is generated based on the response of an external API:

```py
import dataclasses
from typing import Any

from httpx import AsyncClient
from stac_auth_proxy.utils.cache import MemoryCache


@dataclasses.dataclass
class ApprovedCollectionsFilter:
api_url: str
kind: Literal["item", "collection"] = "item"
client: AsyncClient = dataclasses.field(init=False)
cache: MemoryCache = dataclasses.field(init=False)

def __post_init__(self):
# We keep the client in the class instance to avoid creating a new client for
# each request, taking advantage of the client's connection pooling.
self.client = AsyncClient(base_url=self.api_url)
self.cache = MemoryCache(ttl=30)

async def __call__(self, context: dict[str, Any]) -> dict[str, Any]:
token = context["req"]["headers"].get("authorization")

try:
# Check cache for a previously generated filter
approved_collections = self.cache[token]
except KeyError:
# Lookup approved collections from an external API
approved_collections = await self.lookup(token)
self.cache[token] = approved_collections

# Build CQL2 filter
return {
"op": "a_containedby",
"args": [
{"property": "collection" if self.kind == "item" else "id"},
approved_collections
],
}

async def lookup(self, token: Optional[str]) -> list[str]:
# Lookup approved collections from an external API
headers = {"Authorization": f"Bearer {token}"} if token else {}
response = await self.client.get(
f"/get-approved-collections",
headers=headers,
)
response.raise_for_status()
return response.json()["collections"]
```

> [!TIP]
> Filter generation runs for every relevant request. Consider memoizing external API calls to improve performance.

[^21]: https://github.com/developmentseed/stac-auth-proxy/issues/21
[^22]: https://github.com/developmentseed/stac-auth-proxy/issues/22
[^23]: https://github.com/developmentseed/stac-auth-proxy/issues/23
Expand Down
1 change: 1 addition & 0 deletions examples/custom-integration/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
6 changes: 6 additions & 0 deletions examples/custom-integration/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ARG STAC_AUTH_PROXY_VERSION
FROM ghcr.io/developmentseed/stac-auth-proxy:${STAC_AUTH_PROXY_VERSION}

ADD . /opt/stac-auth-proxy-integration

RUN pip install /opt/stac-auth-proxy-integration
11 changes: 11 additions & 0 deletions examples/custom-integration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Custom Integration Example

This example demonstrates how to integrate with a custom filter generator.

## Running the Example

From the root directory, run:

```sh
docker compose -f docker-compose.yaml -f examples/custom-integration/docker-compose.yaml up
```
12 changes: 12 additions & 0 deletions examples/custom-integration/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# This compose file is intended to be run alongside the `docker-compose.yaml` file in the
# root directory.

services:
proxy:
build:
context: examples/custom-integration
args:
STAC_AUTH_PROXY_VERSION: 0.1.2
environment:
ITEMS_FILTER_CLS: custom_integration:cql2_builder
ITEMS_FILTER_KWARGS: '{"admin_user": "user123"}'
7 changes: 7 additions & 0 deletions examples/custom-integration/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[project]
name = "custom_integration"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.9"
dependencies = []
29 changes: 29 additions & 0 deletions examples/custom-integration/src/custom_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""
A custom integration example.

In this example, we're intentionally using a functional pattern but you could also use a
class like we do in the integrations found in stac_auth_proxy.filters.
"""

from typing import Any


def cql2_builder(admin_user: str):
"""CQL2 builder integration filter."""
# NOTE: This is where you would set up things like connection pools.
# NOTE: args/kwargs are passed in via environment variables.

async def custom_integration_filter(ctx: dict[str, Any]) -> str:
"""
Generate CQL2 expressions based on the request context.

Returns a CQL2 expression, either as a string (cql2-text) or as a dict (cql2-json).
"""
# NOTE: This is where you would perform a lookup from a database, API, etc.
# NOTE: ctx is the request context, which includes the payload, headers, etc.

if ctx["payload"] and ctx["payload"]["sub"] == admin_user:
return "1=1"
return "private = true"

return custom_integration_filter
27 changes: 27 additions & 0 deletions examples/opa/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Open Policy Agent (OPA) Integration

This example demonstrates how to integrate with an Open Policy Agent (OPA) to authorize requests to a STAC API.

## Running the Example

From the root directory, run:

```sh
docker compose -f docker-compose.yaml -f examples/opa/docker-compose.yaml up
```

## Testing OPA

```sh
▶ curl -X POST "http://localhost:8181/v1/data/stac/cql2" \
-H "Content-Type: application/json" \
-d '{"input":{"payload": null}}'
{"result":"private = true"}
```

```sh
▶ curl -X POST "http://localhost:8181/v1/data/stac/cql2" \
-H "Content-Type: application/json" \
-d '{"input":{"payload": {"sub": "user1"}}}'
{"result":"1=1"}
```
13 changes: 13 additions & 0 deletions examples/opa/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
services:
proxy:
environment:
ITEMS_FILTER_CLS: stac_auth_proxy.filters:Opa
ITEMS_FILTER_ARGS: '["http://opa:8181", "stac/cql2"]'

opa:
image: openpolicyagent/opa:latest
command: "run --server --addr=:8181 --watch /policies"
ports:
- "8181:8181"
volumes:
- ./examples/opa/policies:/policies
7 changes: 7 additions & 0 deletions examples/opa/policies/stac/policy.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package stac

default cql2 := "\"naip:year\" = 2021"

cql2 := "1=1" if {
input.payload.sub != null
}
6 changes: 5 additions & 1 deletion src/stac_auth_proxy/filters/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"""CQL2 filter generators."""

from .opa import Opa
from .template import Template

__all__ = ["Template"]
__all__ = [
"Opa",
"Template",
]
44 changes: 44 additions & 0 deletions src/stac_auth_proxy/filters/opa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Integration with Open Policy Agent (OPA) to generate CQL2 filters for requests to a STAC API."""

from dataclasses import dataclass, field
from typing import Any

import httpx

from ..utils.cache import MemoryCache, get_value_by_path


@dataclass
class Opa:
"""Call Open Policy Agent (OPA) to generate CQL2 filters from request context."""

host: str
decision: str

client: httpx.AsyncClient = field(init=False)
cache: MemoryCache = field(init=False)
cache_key: str = "req.headers.authorization"
cache_ttl: float = 5.0

def __post_init__(self):
"""Initialize the client."""
self.client = httpx.AsyncClient(base_url=self.host)
self.cache = MemoryCache(ttl=self.cache_ttl)

async def __call__(self, context: dict[str, Any]) -> str:
"""Generate a CQL2 filter for the request."""
token = get_value_by_path(context, self.cache_key)
try:
expr_str = self.cache[token]
except KeyError:
expr_str = await self._fetch(context)
self.cache[token] = expr_str
return expr_str

async def _fetch(self, context: dict[str, Any]) -> str:
"""Fetch the CQL2 filter from OPA."""
response = await self.client.post(
f"/v1/data/{self.decision}",
json={"input": context},
)
return response.raise_for_status().json()["result"]
Loading