From 0509f1d8cb84ea34265331af2dc574785ee5df71 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 14 Apr 2025 23:24:50 -0700 Subject: [PATCH 1/5] feat: enhance CQL2 filter support for collections - Added support for a collections filter in the configuration and middleware. - Updated README to clarify content filtering based on request context. - Refactored middleware to handle both items and collections filters. - Improved error handling in filter application. - Updated tests to include scenarios for collections filtering. --- README.md | 9 +- src/stac_auth_proxy/app.py | 7 +- src/stac_auth_proxy/config.py | 1 + .../middleware/ApplyCql2FilterMiddleware.py | 29 ++-- .../middleware/BuildCql2FilterMiddleware.py | 6 +- tests/conftest.py | 1 + tests/test_filters_jinja2.py | 156 +++++++++++++++++- 7 files changed, 184 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 169e4b69..4d824b8f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ STAC Auth Proxy is a proxy API that mediates between the client and your interna ## ✨Features✨ - **πŸ” 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 +- **πŸ›‚ 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 request context (e.g. user role) - **🀝 External Policy Integration:** Integrate with external systems (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 @@ -227,7 +227,7 @@ The system supports generating CQL2 filters based on request context to provide #### Filters -If enabled, filters are intended to be applied to the following endpoints: +If enabled, filters are applied to the following endpoints: - `GET /search` - **Supported:** βœ… @@ -250,12 +250,12 @@ If enabled, filters are intended to be applied to the following endpoints: - **Applied Filter:** `ITEMS_FILTER` - **Strategy:** Validate response against CQL2 query. - `GET /collections` - - **Supported:** ❌[^23] + - **Supported:** βœ… - **Action:** Read Collection - **Applied Filter:** `COLLECTIONS_FILTER` - **Strategy:** Append query params with generated CQL2 query. - `GET /collections/{collection_id}` - - **Supported:** ❌[^23] + - **Supported:** βœ… - **Action:** Read Collection - **Applied Filter:** `COLLECTIONS_FILTER` - **Strategy:** Validate response against CQL2 query. @@ -411,6 +411,5 @@ class ApprovedCollectionsFilter: [^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 [^30]: https://github.com/developmentseed/stac-auth-proxy/issues/30 [^37]: https://github.com/developmentseed/stac-auth-proxy/issues/37 diff --git a/src/stac_auth_proxy/app.py b/src/stac_auth_proxy/app.py index 7fbf94d7..82264b62 100644 --- a/src/stac_auth_proxy/app.py +++ b/src/stac_auth_proxy/app.py @@ -119,13 +119,16 @@ async def lifespan(app: FastAPI): auth_scheme_override=settings.openapi_auth_scheme_override, ) - if settings.items_filter: + if settings.items_filter or settings.collections_filter: app.add_middleware( ApplyCql2FilterMiddleware, ) app.add_middleware( BuildCql2FilterMiddleware, - items_filter=settings.items_filter(), + items_filter=settings.items_filter() if settings.items_filter else None, + collections_filter=( + settings.collections_filter() if settings.collections_filter else None + ), ) app.add_middleware( diff --git a/src/stac_auth_proxy/config.py b/src/stac_auth_proxy/config.py index 6aea9eb1..ce6cf54b 100644 --- a/src/stac_auth_proxy/config.py +++ b/src/stac_auth_proxy/config.py @@ -71,6 +71,7 @@ class Settings(BaseSettings): # Filters items_filter: Optional[ClassInput] = None + collections_filter: Optional[ClassInput] = None model_config = SettingsConfigDict( env_nested_delimiter="_", diff --git a/src/stac_auth_proxy/middleware/ApplyCql2FilterMiddleware.py b/src/stac_auth_proxy/middleware/ApplyCql2FilterMiddleware.py index 4508b52a..85d2ee0e 100644 --- a/src/stac_auth_proxy/middleware/ApplyCql2FilterMiddleware.py +++ b/src/stac_auth_proxy/middleware/ApplyCql2FilterMiddleware.py @@ -51,7 +51,12 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ) return await req_body_handler(scope, receive, send) - if re.match(r"^/collections/([^/]+)/items/([^/]+)$", request.url.path): + # Handle single record requests (ie non-filterable endpoints) + single_record_endpoints = [ + r"^/collections/([^/]+)/items/([^/]+)$", + r"^/collections/([^/]+)$", + ] + if any(re.match(expr, request.url.path) for expr in single_record_endpoints): res_body_validator = Cql2ResponseBodyValidator( app=self.app, cql2_filter=cql2_filter, @@ -166,15 +171,19 @@ async def buffered_send(message: Message) -> None: logger.debug( "Applying %s filter to %s", self.cql2_filter.to_text(), body_json ) - if self.cql2_filter.matches(body_json): - await send(initial_message) - return await send( - { - "type": "http.response.body", - "body": json.dumps(body_json).encode("utf-8"), - "more_body": False, - } - ) + try: + if self.cql2_filter.matches(body_json): + await send(initial_message) + return await send( + { + "type": "http.response.body", + "body": json.dumps(body_json).encode("utf-8"), + "more_body": False, + } + ) + except Exception as e: + logger.warning("Failed to apply filter: %s", e) + return await _send_error_response(404, "Not found") return await self.app(scope, receive, buffered_send) diff --git a/src/stac_auth_proxy/middleware/BuildCql2FilterMiddleware.py b/src/stac_auth_proxy/middleware/BuildCql2FilterMiddleware.py index 3d985271..84dadd82 100644 --- a/src/stac_auth_proxy/middleware/BuildCql2FilterMiddleware.py +++ b/src/stac_auth_proxy/middleware/BuildCql2FilterMiddleware.py @@ -25,7 +25,9 @@ class BuildCql2FilterMiddleware: # Filters collections_filter: Optional[Callable] = None + collections_filter_path: str = r"^/collections(/[^/]+)?$" items_filter: Optional[Callable] = None + items_filter_path: str = r"^(/collections/([^/]+)/items(/[^/]+)?$|/search$)" async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: """Build the CQL2 filter, place on the request state.""" @@ -65,8 +67,8 @@ def _get_filter( ) -> Optional[Callable[..., Awaitable[str | dict[str, Any]]]]: """Get the CQL2 filter builder for the given path.""" endpoint_filters = [ - (r"^/collections(/[^/]+)?$", self.collections_filter), - (r"^(/collections/([^/]+)/items(/[^/]+)?$|/search$)", self.items_filter), + (self.collections_filter_path, self.collections_filter), + (self.items_filter_path, self.items_filter), ] for expr, builder in endpoint_filters: if re.match(expr, path): diff --git a/tests/conftest.py b/tests/conftest.py index 2d038700..8b7df5d6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -207,6 +207,7 @@ def mock_env(): @pytest.fixture async def mock_upstream() -> AsyncGenerator[MagicMock, None]: """Mock the HTTPX send method. Useful when we want to inspect the request is sent to upstream API.""" + # NOTE: This fixture will interfere with the source_api_responses fixture async def store_body(request, **kwargs): """Exhaust and store the request body.""" diff --git a/tests/test_filters_jinja2.py b/tests/test_filters_jinja2.py index e50ca83f..c5d17e92 100644 --- a/tests/test_filters_jinja2.py +++ b/tests/test_filters_jinja2.py @@ -121,7 +121,7 @@ ) -def _build_client( +def _build_items_filter_client( *, src_api_server: str, template_expr: str, @@ -162,7 +162,7 @@ async def test_search_post( token_builder, ): """Test that POST /search merges the upstream query with the templated filter.""" - response = _build_client( + response = _build_items_filter_client( src_api_server=source_api_server, template_expr=filter_template_expr, is_authenticated=is_authenticated, @@ -210,7 +210,7 @@ async def test_search_get( token_builder, ): """Test that GET /search merges the upstream query params with the templated filter.""" - client = _build_client( + client = _build_items_filter_client( src_api_server=source_api_server, template_expr=filter_template_expr, is_authenticated=is_authenticated, @@ -263,7 +263,7 @@ async def test_items_list( token_builder, ): """Test that GET /collections/foo/items merges query params with the templated filter.""" - client = _build_client( + client = _build_items_filter_client( src_api_server=source_api_server, template_expr=filter_template_expr, is_authenticated=is_authenticated, @@ -296,7 +296,7 @@ def test_item_get( source_api_server, is_authenticated, token_builder, source_api_responses ): """Test that GET /collections/foo/items/bar is rejected.""" - client = _build_client( + client = _build_items_filter_client( src_api_server=source_api_server, template_expr="{{ '(properties.private = false)' if payload is none else true }}", is_authenticated=is_authenticated, @@ -323,7 +323,7 @@ async def test_search_post_empty_body( token_builder, ): """Test that POST /search with empty body.""" - client = _build_client( + client = _build_items_filter_client( src_api_server=source_api_server, template_expr="(properties.private = false)", is_authenticated=is_authenticated, @@ -337,3 +337,147 @@ async def test_search_post_empty_body( ) assert response.status_code == 200 + + +COLLECTIONS_FILTER_CASES = [ + pytest.param( + "(properties.private = false)", + "(properties.private = false)", + "(properties.private = false)", + id="simple_collections_filter", + ), + pytest.param( + "{{ '(properties.private = false)' if payload is none else true }}", + "true", + "(properties.private = false)", + id="templated_collections_filter", + ), +] + +COLLECTIONS_QUERIES = [ + pytest.param( + {}, + id="collections_no_filter", + ), + pytest.param( + { + "filter-lang": "cql2-text", + "filter": "(properties.private = true)", + }, + id="collections_with_filter", + ), +] + + +def _build_collections_filter_client( + *, + src_api_server: str, + template_expr: str, + is_authenticated: bool, + token_builder, +): + """Build a TestClient configured for either authenticated or anonymous usage.""" + app = app_factory( + upstream_url=src_api_server, + collections_filter={ + "cls": "stac_auth_proxy.filters:Template", + "args": [template_expr.strip()], + }, + default_public=True, + ) + headers = ( + {"Authorization": f"Bearer {token_builder({'sub': 'test-user'})}"} + if is_authenticated + else {} + ) + return TestClient(app, headers=headers) + + +@pytest.mark.parametrize( + "filter_template_expr, expected_auth_filter, expected_anon_filter", + COLLECTIONS_FILTER_CASES, +) +@pytest.mark.parametrize("is_authenticated", [True, False], ids=["auth", "anon"]) +@pytest.mark.parametrize("input_query", COLLECTIONS_QUERIES) +async def test_collections_list( + mock_upstream, + source_api_server, + filter_template_expr, + expected_auth_filter, + expected_anon_filter, + is_authenticated, + input_query, + token_builder, +): + """Test that GET /collections merges query params with the templated filter.""" + client = _build_collections_filter_client( + src_api_server=source_api_server, + template_expr=filter_template_expr, + is_authenticated=is_authenticated, + token_builder=token_builder, + ) + response = client.get("/collections", params=input_query) + response.raise_for_status() + + # For GET collections, we expect an empty body and appended querystring + proxied_request = await get_upstream_request(mock_upstream) + assert proxied_request.body == "" + + # Determine the expected combined filter + proxy_filter = cql2.Expr( + expected_auth_filter if is_authenticated else expected_anon_filter + ) + input_filter = input_query.get("filter") + if input_filter: + proxy_filter += cql2.Expr(input_filter) + + filter_lang = input_query.get("filter-lang", "cql2-text") + expected_output = { + **input_query, + "filter": ( + proxy_filter.to_text() + if filter_lang == "cql2-text" + else proxy_filter.to_json() + ), + "filter-lang": filter_lang, + } + assert ( + proxied_request.query_params == expected_output + ), "Collections query should combine filter expressions." + + +@pytest.mark.parametrize( + "filter_template_expr, expected_auth_filter, expected_anon_filter", + COLLECTIONS_FILTER_CASES, +) +@pytest.mark.parametrize("is_authenticated", [True, False], ids=["auth", "anon"]) +async def test_collection_get( + source_api_server, + filter_template_expr, + expected_auth_filter, + expected_anon_filter, + is_authenticated, + token_builder, + source_api_responses, +): + """Test that GET /collections/{collection_id} applies the templated filter.""" + client = _build_collections_filter_client( + src_api_server=source_api_server, + template_expr=filter_template_expr, + is_authenticated=is_authenticated, + token_builder=token_builder, + ) + response_body = { + "id": "foo", + "properties": {"private": True}, + } + source_api_responses["/collections/{collection_id}"]["GET"] = response_body + response = client.get("/collections/foo") + + expected_applied_filter = cql2.Expr( + expected_auth_filter if is_authenticated else expected_anon_filter + ) + expected_response_status = ( + 200 if expected_applied_filter.matches(response_body) else 404 + ) + assert response.status_code == expected_response_status From f20f259b2f53fe3c469038997fa15808e8668238 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 15 Apr 2025 08:57:42 -0700 Subject: [PATCH 2/5] Update OPA example for collection filtering --- examples/opa/docker-compose.yaml | 4 +++- examples/opa/policies/stac/policy.rego | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/examples/opa/docker-compose.yaml b/examples/opa/docker-compose.yaml index 28a9d82b..f8c69122 100644 --- a/examples/opa/docker-compose.yaml +++ b/examples/opa/docker-compose.yaml @@ -2,7 +2,9 @@ services: proxy: environment: ITEMS_FILTER_CLS: stac_auth_proxy.filters:Opa - ITEMS_FILTER_ARGS: '["http://opa:8181", "stac/cql2"]' + ITEMS_FILTER_ARGS: '["http://opa:8181", "stac/items_cql2"]' + COLLECTIONS_FILTER_CLS: stac_auth_proxy.filters:Opa + COLLECTIONS_FILTER_ARGS: '["http://opa:8181", "stac/collections_cql2"]' opa: image: openpolicyagent/opa:latest diff --git a/examples/opa/policies/stac/policy.rego b/examples/opa/policies/stac/policy.rego index e3c25235..a03dd266 100644 --- a/examples/opa/policies/stac/policy.rego +++ b/examples/opa/policies/stac/policy.rego @@ -1,7 +1,13 @@ package stac -default cql2 := "\"naip:year\" = 2021" +default items_cql2 := "\"naip:year\" = 2021" -cql2 := "1=1" if { +items_cql2 := "1=1" if { + input.payload.sub != null +} + +default collections_cql2 := "id = 'naip'" + +collections_cql2 := "1=1" if { input.payload.sub != null } From 0b8c4f0a4cfef0c6d2f48b75e8fc14b85bb06427 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 15 Apr 2025 11:32:07 -0700 Subject: [PATCH 3/5] refactor: enhance CQL2 filter middleware - Removed outdated conformance URLs from ApplyCql2FilterMiddleware. - Refactored error response handling to include error codes in BuildCql2FilterMiddleware. - Improved logging and validation processes for response bodies in Cql2ResponseBodyValidator. - Added required conformance checks in BuildCql2FilterMiddleware based on filter functions. --- .../middleware/ApplyCql2FilterMiddleware.py | 71 +++++++++++-------- .../middleware/BuildCql2FilterMiddleware.py | 34 +++++++++ 2 files changed, 76 insertions(+), 29 deletions(-) diff --git a/src/stac_auth_proxy/middleware/ApplyCql2FilterMiddleware.py b/src/stac_auth_proxy/middleware/ApplyCql2FilterMiddleware.py index 85d2ee0e..aa4f8d58 100644 --- a/src/stac_auth_proxy/middleware/ApplyCql2FilterMiddleware.py +++ b/src/stac_auth_proxy/middleware/ApplyCql2FilterMiddleware.py @@ -21,8 +21,6 @@ r"http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2", r"http://www.opengis.net/spec/cql2/1.0/conf/cql2-text", r"http://www.opengis.net/spec/cql2/1.0/conf/cql2-json", - r"http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter", - r"https://api.stacspec.org/v1\.\d+\.\d+(?:-[\w\.]+)?/item-search#filter", ) @dataclass(frozen=True) class ApplyCql2FilterMiddleware: @@ -31,6 +29,11 @@ class ApplyCql2FilterMiddleware: app: ASGIApp state_key: str = "cql2_filter" + single_record_endpoints = [ + r"^/collections/([^/]+)/items/([^/]+)$", + r"^/collections/([^/]+)$", + ] + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: """Add the Cql2Filter to the request.""" if scope["type"] != "http": @@ -52,11 +55,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: return await req_body_handler(scope, receive, send) # Handle single record requests (ie non-filterable endpoints) - single_record_endpoints = [ - r"^/collections/([^/]+)/items/([^/]+)$", - r"^/collections/([^/]+)$", - ] - if any(re.match(expr, request.url.path) for expr in single_record_endpoints): + if any( + re.match(expr, request.url.path) for expr in self.single_record_endpoints + ): res_body_validator = Cql2ResponseBodyValidator( app=self.app, cql2_filter=cql2_filter, @@ -130,18 +131,22 @@ async def __call__(self, scope: Scope, send: Send, receive: Receive) -> None: body = b"" initial_message: Optional[Message] = None - async def _send_error_response(status: int, message: str) -> None: + async def _send_error_response(status: int, code: str, message: str) -> None: """Send an error response with the given status and message.""" assert initial_message, "Initial message not set" - error_body = json.dumps({"message": message}).encode("utf-8") + response_dict = { + "code": code, + "description": message, + } + response_bytes = json.dumps(response_dict).encode("utf-8") headers = MutableHeaders(scope=initial_message) - headers["content-length"] = str(len(error_body)) + headers["content-length"] = str(len(response_bytes)) initial_message["status"] = status await send(initial_message) await send( { "type": "http.response.body", - "body": error_body, + "body": response_bytes, "more_body": False, } ) @@ -150,13 +155,17 @@ async def buffered_send(message: Message) -> None: """Process a response message and apply filtering if needed.""" nonlocal body nonlocal initial_message + initial_message = initial_message or message + # NOTE: to avoid data-leak, we process 404s so their responses are the same as rejected 200s + should_process = initial_message["status"] in [200, 404] + + if not should_process: + return await send(message) if message["type"] == "http.response.start": - initial_message = message + # Hold off on sending response headers until we've validated the response body return - assert initial_message, "Initial message not set" - body += message["body"] if message.get("more_body"): return @@ -164,26 +173,30 @@ async def buffered_send(message: Message) -> None: try: body_json = json.loads(body) except json.JSONDecodeError: - logger.warning("Failed to parse response body as JSON") - await _send_error_response(502, "Not found") + msg = "Failed to parse response body as JSON" + logger.warning(msg) + await _send_error_response(status=502, code="ParseError", message=msg) return - logger.debug( - "Applying %s filter to %s", self.cql2_filter.to_text(), body_json - ) try: - if self.cql2_filter.matches(body_json): - await send(initial_message) - return await send( - { - "type": "http.response.body", - "body": json.dumps(body_json).encode("utf-8"), - "more_body": False, - } - ) + cql2_matches = self.cql2_filter.matches(body_json) except Exception as e: + cql2_matches = False logger.warning("Failed to apply filter: %s", e) - return await _send_error_response(404, "Not found") + if cql2_matches: + logger.debug("Response matches filter, returning record") + await send(initial_message) + return await send( + { + "type": "http.response.body", + "body": json.dumps(body_json).encode("utf-8"), + "more_body": False, + } + ) + logger.debug("Response did not match filter, returning 404") + return await _send_error_response( + status=404, code="NotFoundError", message="Record not found." + ) return await self.app(scope, receive, buffered_send) diff --git a/src/stac_auth_proxy/middleware/BuildCql2FilterMiddleware.py b/src/stac_auth_proxy/middleware/BuildCql2FilterMiddleware.py index 84dadd82..29ee55fa 100644 --- a/src/stac_auth_proxy/middleware/BuildCql2FilterMiddleware.py +++ b/src/stac_auth_proxy/middleware/BuildCql2FilterMiddleware.py @@ -11,10 +11,16 @@ from starlette.types import ASGIApp, Receive, Scope, Send from ..utils import requests +from ..utils.middleware import required_conformance logger = logging.getLogger(__name__) +@required_conformance( + "http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2", + "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text", + "http://www.opengis.net/spec/cql2/1.0/conf/cql2-json", +) @dataclass(frozen=True) class BuildCql2FilterMiddleware: """Middleware to build the Cql2Filter.""" @@ -29,6 +35,34 @@ class BuildCql2FilterMiddleware: items_filter: Optional[Callable] = None items_filter_path: str = r"^(/collections/([^/]+)/items(/[^/]+)?$|/search$)" + def __post_init__(self): + """Set required conformances based on the filter functions.""" + required_conformances = set() + if self.collections_filter: + logger.debug("Appending required conformance for collections filter") + required_conformances.update( + [ + "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter", + "http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2", + r"https://api.stacspec.org/v1\.0\.0(?:-[\w\.]+)?/item-search#filter", + "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter", + ] + ) + if self.items_filter: + logger.debug("Appending required conformance for items filter") + required_conformances.update( + [ + "https://api.stacspec.org/v1.0.0/core", + r"https://api.stacspec.org/v1\.0\.0(?:-[\w\.]+)?/collection-search#filter", + "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query", + ] + ) + + # Must set required conformances on class + self.__class__.__required_conformances__ = required_conformances.union( + getattr(self.__class__, "__required_conformances__", []) + ) + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: """Build the CQL2 filter, place on the request state.""" if scope["type"] != "http": From 98d7b5c829e24479ad4320553c6d141883ed66f8 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 15 Apr 2025 11:37:46 -0700 Subject: [PATCH 4/5] Fix test --- tests/test_filters_jinja2.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_filters_jinja2.py b/tests/test_filters_jinja2.py index c5d17e92..896dfeb8 100644 --- a/tests/test_filters_jinja2.py +++ b/tests/test_filters_jinja2.py @@ -313,7 +313,10 @@ def test_item_get( assert response.json()["properties"].get("private") is True else: assert response.status_code == 404 - assert response.json() == {"message": "Not found"} + assert response.json() == { + "code": "NotFoundError", + "description": "Record not found.", + } @pytest.mark.parametrize("is_authenticated", [True, False], ids=["auth", "anon"]) From 68ebe5e48a63700551b9f5dd9e932e0f9440ac38 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 15 Apr 2025 11:55:59 -0700 Subject: [PATCH 5/5] Update README --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 4d824b8f..05946971 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,18 @@ The application is configurable via environment variables. - **Type:** Dictionary of keyword arguments used to initialize the class - **Required:** No, defaults to `{}` - **Example:** `{"field_name": "properties.organization"}` + - **`COLLECTIONS_FILTER_CLS`**, CQL2 expression generator for collection-level filtering + - **Type:** JSON object with class configuration + - **Required:** No, defaults to `null` (disabled) + - **Example:** `stac_auth_proxy.filters:Opa`, `stac_auth_proxy.filters:Template`, `my_package:OrganizationFilter` + - **`COLLECTIONS_FILTER_ARGS`**, Positional arguments for CQL2 expression generator + - **Type:** List of positional arguments used to initialize the class + - **Required:** No, defaults to `[]` + - **Example:**: `["org1"]` + - **`COLLECTIONS_FILTER_KWARGS`**, Keyword arguments for CQL2 expression generator + - **Type:** Dictionary of keyword arguments used to initialize the class + - **Required:** No, defaults to `{}` + - **Example:** `{"field_name": "properties.organization"}` ### Tips