Skip to content

Commit 327b9cf

Browse files
authored
feat: Buildout CQL2 filter tooling for reading Items (#17) + Refactor codebase into middleware (#20)
1 parent 11df5de commit 327b9cf

29 files changed

+1412
-820
lines changed

.vscode/launch.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
],
1717
"jinja": true,
1818
"cwd": "${workspaceFolder}/src",
19+
"justMyCode": false
1920
}
2021
]
2122
}

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,25 @@ STAC Auth Proxy is a proxy API that mediates between the client and and some int
99

1010
- 🔐 Selectively apply OIDC auth to some or all endpoints & methods
1111
- 📖 Augments [OpenAPI](https://swagger.io/specification/) with auth information, keeping auto-generated docs (e.g. [Swagger UI](https://swagger.io/tools/swagger-ui/)) accurate
12-
- 💂‍♀️ Custom policies enforce complex access controls, defined with [Common Expression Language (CEL)](https://cel.dev/)
12+
13+
### CQL2 Filters
14+
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. |
23+
24+
#### Recipes
25+
26+
Only return collections that are mentioned in a `collections` array encoded within the auth token.
27+
28+
```
29+
"A_CONTAINEDBY(id, ('{{ token.collections | join(\"', '\") }}' ))"
30+
```
1331

1432
## Installation
1533

pyproject.toml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,20 @@ classifiers = [
88
dependencies = [
99
"authlib>=1.3.2",
1010
"brotli>=1.1.0",
11-
"cel-python>=0.1.5",
12-
"eoapi-auth-utils>=0.4.0",
11+
"cql2>=0.3.4",
1312
"fastapi>=0.115.5",
1413
"httpx>=0.28.0",
14+
"jinja2>=3.1.4",
1515
"pydantic-settings>=2.6.1",
16+
"pyjwt>=2.10.1",
1617
"uvicorn>=0.32.1",
1718
]
1819
description = "STAC authentication proxy with FastAPI"
1920
keywords = ["STAC", "FastAPI", "Authentication", "Proxy"]
2021
license = {file = "LICENSE"}
2122
name = "stac-auth-proxy"
2223
readme = "README.md"
23-
requires-python = ">=3.8"
24+
requires-python = ">=3.9"
2425
version = "0.1.0"
2526

2627
[tool.coverage.run]
@@ -42,6 +43,11 @@ requires = ["hatchling>=1.12.0"]
4243
dev = [
4344
"jwcrypto>=1.5.6",
4445
"pre-commit>=3.5.0",
46+
"pytest-asyncio>=0.25.1",
4547
"pytest-cov>=5.0.0",
4648
"pytest>=8.3.3",
4749
]
50+
51+
[tool.pytest.ini_options]
52+
asyncio_default_fixture_loop_scope = "function"
53+
asyncio_mode = "auto"

src/stac_auth_proxy/app.py

Lines changed: 35 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@
88
import logging
99
from typing import Optional
1010

11-
from eoapi.auth_utils import OpenIdConnectAuth
12-
from fastapi import Depends, FastAPI
11+
from fastapi import FastAPI
1312

1413
from .config import Settings
15-
from .handlers import OpenApiSpecHandler, ReverseProxyHandler
16-
from .middleware import AddProcessTimeHeaderMiddleware
14+
from .handlers import ReverseProxyHandler
15+
from .middleware import (
16+
AddProcessTimeHeaderMiddleware,
17+
ApplyCql2FilterMiddleware,
18+
BuildCql2FilterMiddleware,
19+
EnforceAuthMiddleware,
20+
OpenApiMiddleware,
21+
)
1722

1823
logger = logging.getLogger(__name__)
1924

@@ -23,17 +28,35 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
2328
settings = settings or Settings()
2429

2530
app = FastAPI(
26-
openapi_url=None,
31+
openapi_url=None, # Disable OpenAPI schema endpoint, we want to serve upstream's schema
2732
)
33+
2834
app.add_middleware(AddProcessTimeHeaderMiddleware)
2935

30-
auth_scheme = OpenIdConnectAuth(
31-
openid_configuration_url=str(settings.oidc_discovery_url)
32-
).valid_token_dependency
36+
if settings.openapi_spec_endpoint:
37+
app.add_middleware(
38+
OpenApiMiddleware,
39+
openapi_spec_path=settings.openapi_spec_endpoint,
40+
oidc_config_url=str(settings.oidc_discovery_url),
41+
private_endpoints=settings.private_endpoints,
42+
default_public=settings.default_public,
43+
)
3344

34-
if settings.guard:
35-
logger.info("Wrapping auth scheme")
36-
auth_scheme = settings.guard(auth_scheme)
45+
if settings.items_filter:
46+
app.add_middleware(ApplyCql2FilterMiddleware)
47+
app.add_middleware(
48+
BuildCql2FilterMiddleware,
49+
# collections_filter=settings.collections_filter,
50+
items_filter=settings.items_filter(),
51+
)
52+
53+
app.add_middleware(
54+
EnforceAuthMiddleware,
55+
public_endpoints=settings.public_endpoints,
56+
private_endpoints=settings.private_endpoints,
57+
default_public=settings.default_public,
58+
oidc_config_url=settings.oidc_discovery_url,
59+
)
3760

3861
if settings.debug:
3962
app.add_api_route(
@@ -42,42 +65,12 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
4265
methods=["GET"],
4366
)
4467

68+
# Catchall for any endpoint
4569
proxy_handler = ReverseProxyHandler(upstream=str(settings.upstream_url))
46-
openapi_handler = OpenApiSpecHandler(
47-
proxy=proxy_handler, oidc_config_url=str(settings.oidc_discovery_url)
48-
)
49-
50-
# Endpoints that are explicitely marked private
51-
for path, methods in settings.private_endpoints.items():
52-
app.add_api_route(
53-
path,
54-
(
55-
proxy_handler.stream
56-
if path != settings.openapi_spec_endpoint
57-
else openapi_handler.dispatch
58-
),
59-
methods=methods,
60-
dependencies=[Depends(auth_scheme)],
61-
)
62-
63-
# Endpoints that are explicitely marked as public
64-
for path, methods in settings.public_endpoints.items():
65-
app.add_api_route(
66-
path,
67-
(
68-
proxy_handler.stream
69-
if path != settings.openapi_spec_endpoint
70-
else openapi_handler.dispatch
71-
),
72-
methods=methods,
73-
)
74-
75-
# Catchall for remainder of the endpoints
7670
app.add_api_route(
7771
"/{path:path}",
7872
proxy_handler.stream,
7973
methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
80-
dependencies=([] if settings.default_public else [Depends(auth_scheme)]),
8174
)
8275

8376
return app

src/stac_auth_proxy/config.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import importlib
44
from typing import Optional, Sequence, TypeAlias
55

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

@@ -14,15 +14,15 @@ class ClassInput(BaseModel):
1414
"""Input model for dynamically loading a class or function."""
1515

1616
cls: str
17-
args: Optional[Sequence[str]] = []
18-
kwargs: Optional[dict[str, str]] = {}
17+
args: Sequence[str] = Field(default_factory=list)
18+
kwargs: dict[str, str] = Field(default_factory=dict)
1919

20-
def __call__(self, token_dependency):
21-
"""Dynamically load a class and instantiate it with kwargs."""
20+
def __call__(self):
21+
"""Dynamically load a class and instantiate it with args & kwargs."""
2222
module_path, class_name = self.cls.rsplit(".", 1)
2323
module = importlib.import_module(module_path)
2424
cls = getattr(module, class_name)
25-
return cls(*self.args, **self.kwargs, token_dependency=token_dependency)
25+
return cls(*self.args, **self.kwargs)
2626

2727

2828
class Settings(BaseSettings):
@@ -37,17 +37,26 @@ class Settings(BaseSettings):
3737
default_public: bool = False
3838
private_endpoints: EndpointMethods = {
3939
# https://github.com/stac-api-extensions/collection-transaction/blob/v1.0.0-beta.1/README.md#methods
40-
"/collections": ["POST"],
41-
"/collections/{collection_id}": ["PUT", "PATCH", "DELETE"],
40+
r"^/collections$": ["POST"],
41+
r"^/collections/([^/]+)$": ["PUT", "PATCH", "DELETE"],
4242
# https://github.com/stac-api-extensions/transaction/blob/v1.0.0-rc.3/README.md#methods
43-
"/collections/{collection_id}/items": ["POST"],
44-
"/collections/{collection_id}/items/{item_id}": ["PUT", "PATCH", "DELETE"],
43+
r"^/collections/([^/]+)/items$": ["POST"],
44+
r"^/collections/([^/]+)/items/([^/]+)$": ["PUT", "PATCH", "DELETE"],
4545
# https://stac-utils.github.io/stac-fastapi/api/stac_fastapi/extensions/third_party/bulk_transactions/#bulktransactionextension
46-
"/collections/{collection_id}/bulk_items": ["POST"],
46+
r"^/collections/([^/]+)/bulk_items$": ["POST"],
4747
}
48-
public_endpoints: EndpointMethods = {"/api.html": ["GET"], "/api": ["GET"]}
48+
public_endpoints: EndpointMethods = {r"^/api.html$": ["GET"], r"^/api$": ["GET"]}
4949
openapi_spec_endpoint: Optional[str] = None
5050

51-
model_config = SettingsConfigDict(env_prefix="STAC_AUTH_PROXY_")
51+
# collections_filter: Optional[ClassInput] = None
52+
# collections_filter_endpoints: Optional[EndpointMethods] = {
53+
# r"^/collections$": ["GET"],
54+
# r"^/collections$/([^/]+)": ["GET"],
55+
# }
56+
items_filter: Optional[ClassInput] = None
57+
items_filter_endpoints: Optional[EndpointMethods] = {
58+
r"^/search$": ["POST"],
59+
r"^/collections/([^/]+)/items$": ["GET", "POST"],
60+
}
5261

53-
guard: Optional[ClassInput] = None
62+
model_config = SettingsConfigDict(env_prefix="STAC_AUTH_PROXY_")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""CQL2 filter generators."""
2+
3+
from .template import Template
4+
5+
__all__ = ["Template"]
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Generate CQL2 filter expressions via Jinja2 templating."""
2+
3+
from dataclasses import dataclass, field
4+
from typing import Any
5+
6+
from cql2 import Expr
7+
from jinja2 import BaseLoader, Environment
8+
9+
10+
@dataclass
11+
class Template:
12+
"""Generate CQL2 filter expressions via Jinja2 templating."""
13+
14+
template_str: str
15+
env: Environment = field(init=False)
16+
17+
def __post_init__(self):
18+
"""Initialize the Jinja2 environment."""
19+
self.env = Environment(loader=BaseLoader).from_string(self.template_str)
20+
21+
async def __call__(self, context: dict[str, Any]) -> Expr:
22+
"""Render a CQL2 filter expression with the request and auth token."""
23+
# TODO: How to handle the case where auth_token is null?
24+
cql2_str = self.env.render(**context).strip()
25+
cql2_expr = Expr(cql2_str)
26+
cql2_expr.validate()
27+
return cql2_expr

src/stac_auth_proxy/guards/__init__.py

Lines changed: 0 additions & 5 deletions
This file was deleted.

src/stac_auth_proxy/guards/cel.py

Lines changed: 0 additions & 45 deletions
This file was deleted.
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Handlers to process requests."""
22

3-
from .open_api_spec import OpenApiSpecHandler
43
from .reverse_proxy import ReverseProxyHandler
54

6-
__all__ = ["OpenApiSpecHandler", "ReverseProxyHandler"]
5+
__all__ = ["ReverseProxyHandler"]

0 commit comments

Comments
 (0)