From 4760fe41c363f69cdee536a1f38142b7c91fef3e Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:58:03 -0700 Subject: [PATCH 01/25] fix: wip retrieve tenant from collection --- common/auth/veda_auth/pep_middleware.py | 25 +++++++- common/auth/veda_auth/resource_extractors.py | 60 ++++++++++++++++++-- stac_api/runtime/src/app.py | 28 ++++++++- 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/common/auth/veda_auth/pep_middleware.py b/common/auth/veda_auth/pep_middleware.py index ce90a238..ec0379f7 100644 --- a/common/auth/veda_auth/pep_middleware.py +++ b/common/auth/veda_auth/pep_middleware.py @@ -11,7 +11,13 @@ ResourceNotFoundError, TokenError, ) -from veda_auth.resource_extractors import COLLECTIONS_CREATE_PATH_RE +from veda_auth.resource_extractors import ( + COLLECTIONS_BULK_ITEMS_PATH_RE, + COLLECTIONS_CREATE_PATH_RE, + COLLECTIONS_ITEM_PATH_RE, + COLLECTIONS_ITEMS_PATH_RE, + COLLECTIONS_PATH_RE, +) from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request @@ -39,6 +45,23 @@ class ProtectedRoute: ProtectedRoute(path_re=COLLECTIONS_CREATE_PATH_RE, method="POST", scope="create"), ) +STAC_PROTECTED_ROUTES: Sequence[ProtectedRoute] = ( + # Collections + ProtectedRoute(path_re=COLLECTIONS_CREATE_PATH_RE, method="POST", scope="create"), + ProtectedRoute(path_re=COLLECTIONS_PATH_RE, method="PUT", scope="update"), + ProtectedRoute(path_re=COLLECTIONS_PATH_RE, method="PATCH", scope="update"), + ProtectedRoute(path_re=COLLECTIONS_PATH_RE, method="DELETE", scope="delete"), + # Items + ProtectedRoute(path_re=COLLECTIONS_ITEMS_PATH_RE, method="POST", scope="create"), + ProtectedRoute(path_re=COLLECTIONS_ITEM_PATH_RE, method="PUT", scope="update"), + ProtectedRoute(path_re=COLLECTIONS_ITEM_PATH_RE, method="PATCH", scope="update"), + ProtectedRoute(path_re=COLLECTIONS_ITEM_PATH_RE, method="DELETE", scope="delete"), + # Bulk items + ProtectedRoute( + path_re=COLLECTIONS_BULK_ITEMS_PATH_RE, method="POST", scope="create" + ), +) + def pep_error_response( status_code: int, diff --git a/common/auth/veda_auth/resource_extractors.py b/common/auth/veda_auth/resource_extractors.py index 9901f713..24f50129 100644 --- a/common/auth/veda_auth/resource_extractors.py +++ b/common/auth/veda_auth/resource_extractors.py @@ -10,7 +10,7 @@ import logging import os import re -from typing import Any, Dict, Optional +from typing import Any, Awaitable, Callable, Dict, Optional from fastapi import HTTPException, Request @@ -28,6 +28,8 @@ COLLECTIONS_ITEMS_PATH_RE = r".*?/collections/([^/]+)/items$" COLLECTIONS_BULK_ITEMS_PATH_RE = r".*?/collections/([^/]+)/bulk_items$" +CollectionTenantResolver = Callable[[Request, str], Awaitable[Optional[str]]] + _COLLECTIONS_CREATE_PATH_PATTERN = re.compile(COLLECTIONS_CREATE_PATH_RE) _COLLECTIONS_PATH_PATTERN = re.compile(COLLECTIONS_PATH_RE) _COLLECTIONS_ITEM_PATH_PATTERN = re.compile(COLLECTIONS_ITEM_PATH_RE) @@ -47,6 +49,35 @@ def _stac_item_resource_id(request: Request) -> str: return STAC_ITEM_TEMPLATE.format(tenant) if tenant else STAC_ITEM_PUBLIC +def _get_collection_tenant_resolver( + request: Request, +) -> Optional[CollectionTenantResolver]: + """Return optional collection-tenant resolver from app state if configured""" + app = getattr(request, "app", None) + if app is None: + return None + state = getattr(app, "state", None) + return getattr(state, "collection_tenant_resolver", None) + + +async def _collection_tenant_for_item( + request: Request, collection_id: str +) -> Optional[str]: + """Resolve collection tenant for item operations""" + resolver = _get_collection_tenant_resolver(request) + if not resolver: + return None + try: + return await resolver(request, collection_id) + except Exception as e: + logger.warning( + "Failed to resolve collection tenant for item ops %s: %s", + collection_id, + e, + ) + return None + + def _extract_tenant_from_body( body_data: Dict[str, Any], tenant_field: Optional[str] = None ) -> Optional[str]: @@ -105,11 +136,32 @@ async def extract_stac_resource_id(request: Request) -> Optional[str]: return _stac_collection_resource_id(request) if _COLLECTIONS_ITEM_PATH_PATTERN.match(path): + # For single item operations, prefer collection tenant when available + match = _COLLECTIONS_ITEM_PATH_PATTERN.match(path) + collection_id = match.group(1) if match else None + if collection_id: + tenant = await _collection_tenant_for_item(request, collection_id) + if tenant: + return STAC_ITEM_TEMPLATE.format(tenant) return _stac_item_resource_id(request) - if _COLLECTIONS_ITEMS_PATH_PATTERN.match( - path - ) or _COLLECTIONS_BULK_ITEMS_PATH_PATTERN.match(path): + if _COLLECTIONS_ITEMS_PATH_PATTERN.match(path): + # use collection tenant when available, otherwise collection/public + match = _COLLECTIONS_ITEMS_PATH_PATTERN.match(path) + collection_id = match.group(1) if match else None + if collection_id: + tenant = await _collection_tenant_for_item(request, collection_id) + if tenant: + return STAC_ITEM_TEMPLATE.format(tenant) + return _stac_collection_resource_id(request) + + if _COLLECTIONS_BULK_ITEMS_PATH_PATTERN.match(path): + match = _COLLECTIONS_BULK_ITEMS_PATH_PATTERN.match(path) + collection_id = match.group(1) if match else None + if collection_id: + tenant = await _collection_tenant_for_item(request, collection_id) + if tenant: + return STAC_ITEM_TEMPLATE.format(tenant) return _stac_collection_resource_id(request) if "/queryables" in path or "/search" in path: diff --git a/stac_api/runtime/src/app.py b/stac_api/runtime/src/app.py index bf38094a..4739e6e3 100644 --- a/stac_api/runtime/src/app.py +++ b/stac_api/runtime/src/app.py @@ -3,6 +3,7 @@ """ from contextlib import asynccontextmanager +from typing import Optional from aws_lambda_powertools.metrics import MetricUnit from src.config import ( @@ -56,6 +57,26 @@ async def lifespan(app: FastAPI): postgres_settings=api_settings.postgres_settings, add_write_connection_pool=True, ) + + async def collection_tenant_resolver( + request: Request, collection_id: str + ) -> Optional[str]: + """Resolve a collection's tenant from the database for PEP """ + try: + from stac_fastapi.types.errors import NotFoundError + + collection = await api.client.get_collection( + collection_id, request=request + ) + tenant_field = api_settings.tenant_filter_field + return collection.get(tenant_field) or None + except NotFoundError: + return None + except Exception: + return None + + app.state.collection_tenant_resolver = collection_tenant_resolver + yield await close_db_connection(app) @@ -142,6 +163,10 @@ async def lifespan(app: FastAPI): # Use standard FastAPI app when authentication is disabled app = api.app +# Ensure the proxy app also exposes the resolver to PEP +if hasattr(api.app.state, "collection_tenant_resolver"): + app.state.collection_tenant_resolver = api.app.state.collection_tenant_resolver + def _get_keycloak_pdp_client(): """Build Keycloak PDP client for PEP from UMA resource server credentials stored in AWS Secrets Manager.""" @@ -179,13 +204,14 @@ def _get_keycloak_pdp_client(): "PEP middleware enabled, secret_name=%s", api_settings.keycloak_uma_resource_server_client_secret_name, ) - from veda_auth.pep_middleware import PEPMiddleware + from veda_auth.pep_middleware import PEPMiddleware, STAC_PROTECTED_ROUTES from veda_auth.resource_extractors import extract_stac_resource_id app.add_middleware( PEPMiddleware, pdp_client=_get_keycloak_pdp_client, resource_extractor=extract_stac_resource_id, + protected_routes=STAC_PROTECTED_ROUTES, ) else: logger.info( From 583c1d2d4ef6e20d87627c4da0ddc5c9c8a78367 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:48:36 -0700 Subject: [PATCH 02/25] fix: break up function, fix linting errors --- common/auth/veda_auth/resource_extractors.py | 41 +++++++++++++++----- stac_api/runtime/src/app.py | 8 ++-- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/common/auth/veda_auth/resource_extractors.py b/common/auth/veda_auth/resource_extractors.py index 24f50129..42471ba2 100644 --- a/common/auth/veda_auth/resource_extractors.py +++ b/common/auth/veda_auth/resource_extractors.py @@ -118,15 +118,10 @@ async def _extract_collection_resource_id_from_post_body( return None -async def extract_stac_resource_id(request: Request) -> Optional[str]: - """Extract resource ID for STAC API requests - Resource ID format matches Keycloak resource definitions (wildcard patterns): - - Collections: STAC_COLLECTION_TEMPLATE or STAC_COLLECTION_PUBLIC - - Items: STAC_ITEM_TEMPLATE or STAC_ITEM_PUBLIC - """ - path = request.url.path - method = request.method - +async def _extract_collection_stac_resource_id( + request: Request, path: str, method: str +) -> Optional[str]: + """Extract resource ID for collection endpoints, or None if not a collection path""" if _COLLECTIONS_CREATE_PATH_PATTERN.match(path) and method == "POST": return await _extract_collection_resource_id_from_post_body(request) @@ -135,6 +130,13 @@ async def extract_stac_resource_id(request: Request) -> Optional[str]: return await _extract_collection_resource_id_from_post_body(request) return _stac_collection_resource_id(request) + return None + + +async def _extract_item_stac_resource_id( + request: Request, path: str, method: str +) -> Optional[str]: + """Extract resource ID for item endpoints, or None if not an item path""" if _COLLECTIONS_ITEM_PATH_PATTERN.match(path): # For single item operations, prefer collection tenant when available match = _COLLECTIONS_ITEM_PATH_PATTERN.match(path) @@ -164,6 +166,27 @@ async def extract_stac_resource_id(request: Request) -> Optional[str]: return STAC_ITEM_TEMPLATE.format(tenant) return _stac_collection_resource_id(request) + return None + + +async def extract_stac_resource_id(request: Request) -> Optional[str]: + """Extract resource ID for STAC API requests + + Resource ID format matches Keycloak resource definitions (wildcard patterns): + - Collections: STAC_COLLECTION_TEMPLATE or STAC_COLLECTION_PUBLIC + - Items: STAC_ITEM_TEMPLATE or STAC_ITEM_PUBLIC + """ + path = request.url.path + method = request.method + + collection_id = await _extract_collection_stac_resource_id(request, path, method) + if collection_id is not None: + return collection_id + + item_id = await _extract_item_stac_resource_id(request, path, method) + if item_id is not None: + return item_id + if "/queryables" in path or "/search" in path: return None diff --git a/stac_api/runtime/src/app.py b/stac_api/runtime/src/app.py index 4739e6e3..08442355 100644 --- a/stac_api/runtime/src/app.py +++ b/stac_api/runtime/src/app.py @@ -61,13 +61,11 @@ async def lifespan(app: FastAPI): async def collection_tenant_resolver( request: Request, collection_id: str ) -> Optional[str]: - """Resolve a collection's tenant from the database for PEP """ + """Resolve a collection's tenant from the database for PEP""" try: from stac_fastapi.types.errors import NotFoundError - collection = await api.client.get_collection( - collection_id, request=request - ) + collection = await api.client.get_collection(collection_id, request=request) tenant_field = api_settings.tenant_filter_field return collection.get(tenant_field) or None except NotFoundError: @@ -204,7 +202,7 @@ def _get_keycloak_pdp_client(): "PEP middleware enabled, secret_name=%s", api_settings.keycloak_uma_resource_server_client_secret_name, ) - from veda_auth.pep_middleware import PEPMiddleware, STAC_PROTECTED_ROUTES + from veda_auth.pep_middleware import STAC_PROTECTED_ROUTES, PEPMiddleware from veda_auth.resource_extractors import extract_stac_resource_id app.add_middleware( From c62415bf1893143a7c37e70e61775f6c127c864a Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:56:19 -0700 Subject: [PATCH 03/25] fix: add some tests --- common/auth/tests/test_resource_extractors.py | 62 ++++++++++++++++++ .../runtime/tests/test_pep_integration.py | 64 ++++++++++++++++++- 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/common/auth/tests/test_resource_extractors.py b/common/auth/tests/test_resource_extractors.py index a8422169..28fa6707 100644 --- a/common/auth/tests/test_resource_extractors.py +++ b/common/auth/tests/test_resource_extractors.py @@ -7,6 +7,7 @@ from veda_auth.resource_extractors import ( STAC_COLLECTION_PUBLIC, STAC_COLLECTION_TEMPLATE, + STAC_ITEM_PUBLIC, STAC_ITEM_TEMPLATE, _extract_collection_resource_id_from_post_body, _extract_tenant_from_body, @@ -174,6 +175,17 @@ async def test_get_item_with_tenant(self): result = await extract_stac_resource_id(request) assert result == STAC_ITEM_TEMPLATE.format("test-tenant") + @pytest.mark.asyncio + async def test_get_item_without_tenant_uses_public(self): + """Test extracting resource ID for GET item without tenant (defaults to public)""" + request = MagicMock(spec=Request) + request.url.path = "/collections/test-collection/items/test-item" + request.method = "GET" + request.state = MagicMock() + + result = await extract_stac_resource_id(request) + assert result == STAC_ITEM_TEMPLATE.format("public") + @pytest.mark.asyncio async def test_post_items_with_tenant(self): """Test extracting resource ID for POST items with tenant""" @@ -196,6 +208,56 @@ async def test_post_bulk_items_with_tenant(self): result = await extract_stac_resource_id(request) assert result == STAC_COLLECTION_TEMPLATE.format("test-tenant") + @pytest.mark.asyncio + async def test_item_paths_use_collection_tenant_resolver_when_available(self): + """Item endpoints should use collection_tenant_resolver when configured on app state""" + resolver = AsyncMock(return_value="resolver-tenant") + + def _build_request(path: str, method: str) -> Request: + request = MagicMock(spec=Request) + request.url.path = path + request.method = method + request.state = MagicMock() + app = MagicMock() + app.state.collection_tenant_resolver = resolver + request.app = app + return request + + item_request = _build_request( + "/collections/test-collection/items/test-item", "GET" + ) + item_result = await extract_stac_resource_id(item_request) + assert item_result == STAC_ITEM_TEMPLATE.format("resolver-tenant") + + items_request = _build_request("/collections/test-collection/items", "POST") + items_result = await extract_stac_resource_id(items_request) + assert items_result == STAC_ITEM_TEMPLATE.format("resolver-tenant") + + @pytest.mark.asyncio + async def test_collection_tenant_resolver_failure_falls_back_to_public(self): + """When collection_tenant_resolver fails, item requests fall back to public""" + resolver = AsyncMock(side_effect=Exception("resolver failed")) + + def _build_request(path: str, method: str) -> Request: + request = MagicMock(spec=Request) + request.url.path = path + request.method = method + request.state = MagicMock() + app = MagicMock() + app.state.collection_tenant_resolver = resolver + request.app = app + return request + + item_request = _build_request( + "/collections/test-collection/items/test-item", "GET" + ) + item_result = await extract_stac_resource_id(item_request) + assert item_result == STAC_ITEM_PUBLIC + + items_request = _build_request("/collections/test-collection/items", "POST") + items_result = await extract_stac_resource_id(items_request) + assert items_result == STAC_COLLECTION_PUBLIC + class TestExtractIngestResourceId: """Test Ingest API resource ID extraction""" diff --git a/stac_api/runtime/tests/test_pep_integration.py b/stac_api/runtime/tests/test_pep_integration.py index 1ddf9ce6..7ab19bd8 100644 --- a/stac_api/runtime/tests/test_pep_integration.py +++ b/stac_api/runtime/tests/test_pep_integration.py @@ -105,8 +105,31 @@ def _collection(tenant: Optional[str] = None) -> dict: return body +def _item(collection_id: str, item_id: Optional[str] = None) -> dict: + """Build a valid STAC item for PEP tests based on https://github.com/radiantearth/stac-spec/blob/master/item-spec/item-spec.md""" + provider_id = item_id or f"pep-item-{uuid.uuid4().hex[:8]}" + return { + "type": "Feature", + "stac_version": "1.0.0", + "id": provider_id, + "geometry": { + "type": "Polygon", + "coordinates": [ + [[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]] + ], + }, + "bbox": [-180.0, -90.0, 180.0, 90.0], + "collection": collection_id, + "properties": { + "datetime": "2017-12-31T00:00:00", + }, + "links": [], + "assets": {}, + } + + class TestPEPIntegration: - """Integration tests for PEP middleware for POST /collections endpoint""" + """Integration tests for PEP middleware for STAC collection and item endpoints""" @pytest.mark.asyncio async def test_post_collection_no_token_returns_401(self, pep_client): @@ -140,6 +163,45 @@ async def test_post_collection_authorized_succeeds( headers={"Authorization": "Bearer fake-valid-token"}, ) + @pytest.mark.asyncio + async def test_post_item_with_tenant_uses_item_resource( + self, pep_client, mock_pdp_client + ): + """POST /collections/{collection_id}/items with tenant should use item resource ID""" + mock_pdp_client.check_permission.return_value = True + collection = _collection(tenant="veda") + + # Create a collection with tenant + create_collection_response = await pep_client.post( + COLLECTIONS_ENDPOINT, + json=collection, + headers={"Authorization": "Bearer fake-valid-token"}, + ) + assert create_collection_response.status_code == 201 + + item = _item(collection["id"]) + + response = await pep_client.post( + f"{COLLECTIONS_ENDPOINT}/{collection['id']}/items", + json=item, + headers={"Authorization": "Bearer fake-valid-token"}, + ) + assert response.status_code in (200, 201) + + calls = mock_pdp_client.check_permission.call_args_list + resource_ids = [c.kwargs.get("resource_id") for c in calls] + assert "stac:item:veda:*" in resource_ids + + # Cleanup + await pep_client.delete( + f"{COLLECTIONS_ENDPOINT}/{collection['id']}/items/{item['id']}", + headers={"Authorization": "Bearer fake-valid-token"}, + ) + await pep_client.delete( + f"{COLLECTIONS_ENDPOINT}/{collection['id']}", + headers={"Authorization": "Bearer fake-valid-token"}, + ) + @pytest.mark.asyncio async def test_post_collection_denied_returns_403( self, pep_client, mock_pdp_client From fad4908cfffdad471d84cb0a8fae51e9ef083033 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:26:21 -0700 Subject: [PATCH 04/25] fix: update tests to use item from conftest --- .../runtime/tests/test_pep_integration.py | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/stac_api/runtime/tests/test_pep_integration.py b/stac_api/runtime/tests/test_pep_integration.py index 7ab19bd8..dd861902 100644 --- a/stac_api/runtime/tests/test_pep_integration.py +++ b/stac_api/runtime/tests/test_pep_integration.py @@ -2,6 +2,7 @@ import importlib import os import uuid +from copy import deepcopy from typing import Optional from unittest.mock import MagicMock, patch @@ -13,6 +14,8 @@ from stac_fastapi.pgstac.db import close_db_connection, connect_to_db +from .conftest import VALID_ITEM + VALID_COLLECTION_TEMPLATE = { "type": "Collection", "title": "Test Collection for PEP", @@ -106,26 +109,12 @@ def _collection(tenant: Optional[str] = None) -> dict: def _item(collection_id: str, item_id: Optional[str] = None) -> dict: - """Build a valid STAC item for PEP tests based on https://github.com/radiantearth/stac-spec/blob/master/item-spec/item-spec.md""" + """Build a valid STAC item for PEP tests""" + item = deepcopy(VALID_ITEM) provider_id = item_id or f"pep-item-{uuid.uuid4().hex[:8]}" - return { - "type": "Feature", - "stac_version": "1.0.0", - "id": provider_id, - "geometry": { - "type": "Polygon", - "coordinates": [ - [[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]] - ], - }, - "bbox": [-180.0, -90.0, 180.0, 90.0], - "collection": collection_id, - "properties": { - "datetime": "2017-12-31T00:00:00", - }, - "links": [], - "assets": {}, - } + item["id"] = provider_id + item["collection"] = collection_id + return item class TestPEPIntegration: From 62e052b88d84612a15f37606342d8d30e75b2644 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:20:45 -0700 Subject: [PATCH 05/25] fix: move resolver outside of lifespan --- stac_api/runtime/src/app.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/stac_api/runtime/src/app.py b/stac_api/runtime/src/app.py index 08442355..46a4666f 100644 --- a/stac_api/runtime/src/app.py +++ b/stac_api/runtime/src/app.py @@ -57,24 +57,6 @@ async def lifespan(app: FastAPI): postgres_settings=api_settings.postgres_settings, add_write_connection_pool=True, ) - - async def collection_tenant_resolver( - request: Request, collection_id: str - ) -> Optional[str]: - """Resolve a collection's tenant from the database for PEP""" - try: - from stac_fastapi.types.errors import NotFoundError - - collection = await api.client.get_collection(collection_id, request=request) - tenant_field = api_settings.tenant_filter_field - return collection.get(tenant_field) or None - except NotFoundError: - return None - except Exception: - return None - - app.state.collection_tenant_resolver = collection_tenant_resolver - yield await close_db_connection(app) @@ -113,6 +95,25 @@ async def collection_tenant_resolver( ], ) + +async def collection_tenant_resolver( + request: Request, collection_id: str +) -> Optional[str]: + """Resolve a collection's tenant from the database for PEP""" + try: + from stac_fastapi.types.errors import NotFoundError + + collection = await api.client.get_collection(collection_id, request=request) + tenant_field = api_settings.tenant_filter_field + return collection.get(tenant_field) or None + except NotFoundError: + return None + except Exception: + return None + + +api.app.state.collection_tenant_resolver = collection_tenant_resolver + if api_settings.openid_configuration_url and api_settings.enable_stac_auth_proxy: # Use stac-auth-proxy when authentication is enabled, which it will be for production envs app = configure_app( From 793e74b717ae18cfc9d6484b5feee5ad267f182d Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:49:47 -0700 Subject: [PATCH 06/25] fix: update resource extractors to check for tenant strings: --- common/auth/veda_auth/resource_extractors.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/common/auth/veda_auth/resource_extractors.py b/common/auth/veda_auth/resource_extractors.py index 42471ba2..99e4e733 100644 --- a/common/auth/veda_auth/resource_extractors.py +++ b/common/auth/veda_auth/resource_extractors.py @@ -40,13 +40,17 @@ def _stac_collection_resource_id(request: Request) -> str: """Return tenant-based or public STAC collection resource ID.""" tenant = getattr(request.state, "tenant", None) - return STAC_COLLECTION_TEMPLATE.format(tenant) if tenant else STAC_COLLECTION_PUBLIC + if not isinstance(tenant, str) or not tenant: + return STAC_COLLECTION_PUBLIC + return STAC_COLLECTION_TEMPLATE.format(tenant) def _stac_item_resource_id(request: Request) -> str: """Return tenant-based or public STAC item resource ID.""" tenant = getattr(request.state, "tenant", None) - return STAC_ITEM_TEMPLATE.format(tenant) if tenant else STAC_ITEM_PUBLIC + if not isinstance(tenant, str) or not tenant: + return STAC_ITEM_PUBLIC + return STAC_ITEM_TEMPLATE.format(tenant) def _get_collection_tenant_resolver( From fb8dd9845b4126f3c6b058bde68862270abb15bd Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:29:27 -0700 Subject: [PATCH 07/25] feat: wip extend to ingest api --- common/auth/tests/test_resource_extractors.py | 54 +++++++++++++++++++ common/auth/veda_auth/resource_extractors.py | 8 +++ .../runtime/src/collection_publisher.py | 24 +++++++++ ingest_api/runtime/src/main.py | 11 ++++ 4 files changed, 97 insertions(+) diff --git a/common/auth/tests/test_resource_extractors.py b/common/auth/tests/test_resource_extractors.py index 28fa6707..fe18e3fc 100644 --- a/common/auth/tests/test_resource_extractors.py +++ b/common/auth/tests/test_resource_extractors.py @@ -1,6 +1,7 @@ """Tests for resource extractors""" import json +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -268,6 +269,59 @@ async def test_delete_collection_returns_collection_id(self): request.url.path = "/collections/test-collection" request.method = "DELETE" request.state.tenant = "test-tenant" + request.app = SimpleNamespace(state=SimpleNamespace()) resource_id = await extract_ingest_resource_id(request) assert resource_id == "collection:test-collection" + + async def test_delete_collection_without_resolver_still_returns_collection_id(self): + """DELETE without collection_tenant_resolver on app state returns collection:{id}.""" + request = MagicMock(spec=Request) + request.url.path = "/collections/foo" + request.method = "DELETE" + request.state = SimpleNamespace() + request.app = SimpleNamespace(state=SimpleNamespace()) + + assert await extract_ingest_resource_id(request) == "collection:foo" + + async def test_delete_collection_invokes_resolver_resource_id_unchanged(self): + """When resolver is set, it is awaited; resource id stays collection:{id} (phase 1).""" + resolver = AsyncMock(return_value="veda") + request = MagicMock(spec=Request) + request.url.path = "/collections/foo" + request.method = "DELETE" + request.state = SimpleNamespace() + request.app = SimpleNamespace( + state=SimpleNamespace(collection_tenant_resolver=resolver) + ) + + rid = await extract_ingest_resource_id(request) + assert rid == "collection:foo" + resolver.assert_awaited_once_with(request, "foo") + + async def test_delete_collection_resolver_raises_still_returns_collection_id(self): + """Resolver failures are swallowed by shared helper; resource id unchanged.""" + resolver = AsyncMock(side_effect=RuntimeError("db unavailable")) + request = MagicMock(spec=Request) + request.url.path = "/collections/foo" + request.method = "DELETE" + request.state = SimpleNamespace() + request.app = SimpleNamespace( + state=SimpleNamespace(collection_tenant_resolver=resolver) + ) + + assert await extract_ingest_resource_id(request) == "collection:foo" + + async def test_delete_collection_magicmock_state_resolver_still_works(self): + """Resolver runs even when request.state is a MagicMock (no real tenant).""" + resolver = AsyncMock(return_value="tenant-a") + request = MagicMock(spec=Request) + request.url.path = "/collections/bar" + request.method = "DELETE" + request.state = MagicMock() + request.app = SimpleNamespace( + state=SimpleNamespace(collection_tenant_resolver=resolver) + ) + + assert await extract_ingest_resource_id(request) == "collection:bar" + resolver.assert_awaited_once_with(request, "bar") diff --git a/common/auth/veda_auth/resource_extractors.py b/common/auth/veda_auth/resource_extractors.py index 99e4e733..ad9af073 100644 --- a/common/auth/veda_auth/resource_extractors.py +++ b/common/auth/veda_auth/resource_extractors.py @@ -208,6 +208,14 @@ async def extract_ingest_resource_id(request: Request) -> Optional[str]: match = re.match(r".*?/collections/([^/]+)$", path) if match and method == "DELETE": collection_id = match.group(1) + resolved_tenant = await _collection_tenant_for_item(request, collection_id) + if resolved_tenant: + logger.debug( + "Ingest DELETE /collections/%s: resolved tenant=%s (resource_id remains collection:%s)", + collection_id, + resolved_tenant, + collection_id, + ) return f"collection:{collection_id}" return None diff --git a/ingest_api/runtime/src/collection_publisher.py b/ingest_api/runtime/src/collection_publisher.py index e8ac7ae0..c4692e94 100644 --- a/ingest_api/runtime/src/collection_publisher.py +++ b/ingest_api/runtime/src/collection_publisher.py @@ -1,4 +1,6 @@ +import logging import os +from typing import Any, Dict, Optional from pypgstac.db import PgstacDB from src.schemas import DashboardCollection @@ -6,6 +8,8 @@ from src.vedaloader import VEDALoader from stac_pydantic import Item +logger = logging.getLogger(__name__) + class CollectionPublisher: def ingest(self, collection: DashboardCollection): @@ -30,6 +34,26 @@ def delete(self, collection_id: str): loader = VEDALoader(db=db) loader.delete_collection(collection_id) + def get_collection_tenant(self, collection_id: str) -> Optional[str]: + """Return tenant field from collection JSON in PgSTAC, or None if not found""" + tenant_field = os.getenv("VEDA_TENANT_FILTER_FIELD", "eic:tenant") + creds = get_db_credentials(os.environ["DB_SECRET_ARN"]) + try: + with PgstacDB(dsn=creds.dsn_string, debug=True) as db: + loader = VEDALoader(db=db) + base_item: Dict[str, Any] + base_item, _, _ = loader.collection_json(collection_id) + except Exception: + logger.debug( + "Could not load collection %s for tenant lookup", + collection_id, + ) + return None + if not isinstance(base_item, dict): + return None + val = base_item.get(tenant_field) + return str(val) if val else None + class ItemPublisher: def ingest(self, item: Item): diff --git a/ingest_api/runtime/src/main.py b/ingest_api/runtime/src/main.py index 9ec6f1da..f6f2cd42 100644 --- a/ingest_api/runtime/src/main.py +++ b/ingest_api/runtime/src/main.py @@ -1,4 +1,5 @@ import logging +from typing import Optional import src.dependencies as dependencies import src.schemas as schemas @@ -44,6 +45,16 @@ item_publisher = ItemPublisher() +async def collection_tenant_resolver( + _request: Request, collection_id: str +) -> Optional[str]: + """Resolve tenant from the collection record in PgSTAC""" + return collection_publisher.get_collection_tenant(collection_id) + + +app.state.collection_tenant_resolver = collection_tenant_resolver + + @app.get( "/ingestions", response_model=schemas.ListIngestionResponse, tags=["Ingestion"] ) From ea3a2434796b4b7cb67583d1a87ff51a7ee6c737 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:36:15 -0700 Subject: [PATCH 08/25] fix: linting and set tenant resolver always --- common/auth/veda_auth/pep_middleware.py | 8 -------- stac_api/runtime/src/app.py | 4 +--- stac_api/runtime/tests/test_pep_integration.py | 1 - 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/common/auth/veda_auth/pep_middleware.py b/common/auth/veda_auth/pep_middleware.py index 5751573a..fc48ed7f 100644 --- a/common/auth/veda_auth/pep_middleware.py +++ b/common/auth/veda_auth/pep_middleware.py @@ -49,14 +49,6 @@ class ProtectedRoute: DEFAULT_PROTECTED_ROUTES: Sequence[ProtectedRoute] = (CREATE_COLLECTION_ROUTE,) - -STAC_PROTECTED_ROUTES: Sequence[ProtectedRoute] = ( - # Collections - ProtectedRoute(path_re=COLLECTIONS_CREATE_PATH_RE, method="POST", scope="create"), - ProtectedRoute(path_re=COLLECTIONS_PATH_RE, method="PUT", scope="update"), - ProtectedRoute(path_re=COLLECTIONS_PATH_RE, method="PATCH", scope="update"), -) - STAC_PROTECTED_ROUTES: Sequence[ProtectedRoute] = ( # Collections ProtectedRoute(path_re=COLLECTIONS_CREATE_PATH_RE, method="POST", scope="create"), diff --git a/stac_api/runtime/src/app.py b/stac_api/runtime/src/app.py index 31c028e9..69d30038 100644 --- a/stac_api/runtime/src/app.py +++ b/stac_api/runtime/src/app.py @@ -163,9 +163,7 @@ async def collection_tenant_resolver( # Use standard FastAPI app when authentication is disabled app = api.app -# Ensure the proxy app also exposes the resolver to PEP -if hasattr(api.app.state, "collection_tenant_resolver"): - app.state.collection_tenant_resolver = api.app.state.collection_tenant_resolver +app.state.collection_tenant_resolver = api.app.state.collection_tenant_resolver def _get_keycloak_pdp_client(): diff --git a/stac_api/runtime/tests/test_pep_integration.py b/stac_api/runtime/tests/test_pep_integration.py index 75197bcb..38309c25 100644 --- a/stac_api/runtime/tests/test_pep_integration.py +++ b/stac_api/runtime/tests/test_pep_integration.py @@ -3,7 +3,6 @@ import importlib import os import uuid -from copy import deepcopy from typing import Optional from unittest.mock import MagicMock, patch From 2f862dcd6e11e3703607d521ab6b6df93681cec2 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:24:46 -0700 Subject: [PATCH 09/25] fix: fix template response --- stac_api/runtime/setup.py | 2 +- stac_api/runtime/src/app.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/stac_api/runtime/setup.py b/stac_api/runtime/setup.py index e330b299..7dbd4190 100644 --- a/stac_api/runtime/setup.py +++ b/stac_api/runtime/setup.py @@ -35,7 +35,7 @@ description="", python_requires=">=3.12", packages=find_namespace_packages(exclude=["tests*"]), - package_data={"veda": ["stac/templates/*.html"]}, + package_data={"src": ["templates/*.html"]}, include_package_data=True, zip_safe=False, install_requires=inst_reqs, diff --git a/stac_api/runtime/src/app.py b/stac_api/runtime/src/app.py index 69d30038..112511f4 100644 --- a/stac_api/runtime/src/app.py +++ b/stac_api/runtime/src/app.py @@ -243,10 +243,10 @@ def _get_keycloak_pdp_client(): @app.get("/index.html", response_class=HTMLResponse) async def viewer_page(request: Request): """Search viewer.""" - path = api_settings.root_path or "" return templates.TemplateResponse( + request, "stac-viewer.html", - {"request": request, "endpoint": str(request.url).replace("/index.html", path)}, + {"endpoint": str(request.url).replace("/index.html", "")}, media_type="text/html", ) From 205ae6b1cb0305016e9b9d19f17bffee7c52c7eb Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:41:31 -0700 Subject: [PATCH 10/25] fix: extend tenant lookup resolver to stac delete endpoint --- common/auth/tests/test_resource_extractors.py | 41 +++++++++++++++++++ common/auth/veda_auth/resource_extractors.py | 9 +++- .../runtime/tests/test_pep_integration.py | 24 +++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/common/auth/tests/test_resource_extractors.py b/common/auth/tests/test_resource_extractors.py index fe18e3fc..c9101c5a 100644 --- a/common/auth/tests/test_resource_extractors.py +++ b/common/auth/tests/test_resource_extractors.py @@ -259,6 +259,47 @@ def _build_request(path: str, method: str) -> Request: items_result = await extract_stac_resource_id(items_request) assert items_result == STAC_COLLECTION_PUBLIC + delete_collection_request = _build_request( + "/collections/test-collection", "DELETE" + ) + delete_collection_result = await extract_stac_resource_id( + delete_collection_request + ) + assert delete_collection_result == STAC_COLLECTION_PUBLIC + + @pytest.mark.asyncio + async def test_delete_collection_uses_collection_tenant_resolver_when_available( + self, + ): + """DELETE /collections/{id} should use collection_tenant_resolver""" + resolver = AsyncMock(return_value="some-tenant") + request = MagicMock(spec=Request) + request.url.path = "/collections/test-collection" + request.method = "DELETE" + request.state = MagicMock() + app = MagicMock() + app.state.collection_tenant_resolver = resolver + request.app = app + + result = await extract_stac_resource_id(request) + assert result == STAC_COLLECTION_TEMPLATE.format("some-tenant") + resolver.assert_awaited_once_with(request, "test-collection") + + @pytest.mark.asyncio + async def test_delete_collection_resolver_none_falls_back_to_url_tenant(self): + """When resolver returns None, fall back to request.state.tenant if present""" + resolver = AsyncMock(return_value=None) + request = MagicMock(spec=Request) + request.url.path = "/collections/my-col" + request.method = "DELETE" + request.state = SimpleNamespace(tenant="url-tenant") + request.app = SimpleNamespace( + state=SimpleNamespace(collection_tenant_resolver=resolver) + ) + + result = await extract_stac_resource_id(request) + assert result == STAC_COLLECTION_TEMPLATE.format("url-tenant") + class TestExtractIngestResourceId: """Test Ingest API resource ID extraction""" diff --git a/common/auth/veda_auth/resource_extractors.py b/common/auth/veda_auth/resource_extractors.py index ad9af073..97952acd 100644 --- a/common/auth/veda_auth/resource_extractors.py +++ b/common/auth/veda_auth/resource_extractors.py @@ -129,9 +129,16 @@ async def _extract_collection_stac_resource_id( if _COLLECTIONS_CREATE_PATH_PATTERN.match(path) and method == "POST": return await _extract_collection_resource_id_from_post_body(request) - if _COLLECTIONS_PATH_PATTERN.match(path): + match = _COLLECTIONS_PATH_PATTERN.match(path) + if match: if method in ("PUT", "PATCH"): return await _extract_collection_resource_id_from_post_body(request) + if method == "DELETE": + collection_id = match.group(1) + tenant = await _collection_tenant_for_item(request, collection_id) + if tenant: + return STAC_COLLECTION_TEMPLATE.format(tenant) + return _stac_collection_resource_id(request) return _stac_collection_resource_id(request) return None diff --git a/stac_api/runtime/tests/test_pep_integration.py b/stac_api/runtime/tests/test_pep_integration.py index 38309c25..02860a15 100644 --- a/stac_api/runtime/tests/test_pep_integration.py +++ b/stac_api/runtime/tests/test_pep_integration.py @@ -393,3 +393,27 @@ async def test_patch_collection_authorized_succeeds( await pep_client.delete( f"{COLLECTIONS_ENDPOINT}/{collection['id']}", headers=AUTH_HEADERS ) + + @pytest.mark.asyncio + async def test_delete_collection_uses_resolved_tenant_for_pep_resource_id( + self, pep_client, mock_pdp_client + ): + """DELETE /collections/{id} passes tenant from collection JSON to PDP""" + mock_pdp_client.check_permission.return_value = True + collection = _collection(tenant="veda") + await pep_client.post( + COLLECTIONS_ENDPOINT, + json=collection, + headers=AUTH_HEADERS, + ) + mock_pdp_client.reset_mock() + + response = await pep_client.delete( + f"{COLLECTIONS_ENDPOINT}/{collection['id']}", + headers=AUTH_HEADERS, + ) + assert response.status_code in (200, 204) + mock_pdp_client.check_permission.assert_called_once() + call_kwargs = mock_pdp_client.check_permission.call_args.kwargs + assert call_kwargs.get("resource_id") == "stac:collection:veda:*" + assert call_kwargs.get("scope") == "delete" From 45cf9908b779bbbcf08366738c1d6dc3362deebf Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:46:02 -0700 Subject: [PATCH 11/25] feat: extend pep to delete ingest endpoint --- common/auth/veda_auth/pep_middleware.py | 5 +++++ common/auth/veda_auth/resource_extractors.py | 14 ++++++++------ ingest_api/runtime/src/main.py | 3 ++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/common/auth/veda_auth/pep_middleware.py b/common/auth/veda_auth/pep_middleware.py index fc48ed7f..edc54582 100644 --- a/common/auth/veda_auth/pep_middleware.py +++ b/common/auth/veda_auth/pep_middleware.py @@ -49,6 +49,11 @@ class ProtectedRoute: DEFAULT_PROTECTED_ROUTES: Sequence[ProtectedRoute] = (CREATE_COLLECTION_ROUTE,) +INGEST_PROTECTED_ROUTES: Sequence[ProtectedRoute] = ( + CREATE_COLLECTION_ROUTE, + ProtectedRoute(path_re=COLLECTIONS_PATH_RE, method="DELETE", scope="delete"), +) + STAC_PROTECTED_ROUTES: Sequence[ProtectedRoute] = ( # Collections ProtectedRoute(path_re=COLLECTIONS_CREATE_PATH_RE, method="POST", scope="create"), diff --git a/common/auth/veda_auth/resource_extractors.py b/common/auth/veda_auth/resource_extractors.py index 97952acd..827c992f 100644 --- a/common/auth/veda_auth/resource_extractors.py +++ b/common/auth/veda_auth/resource_extractors.py @@ -215,14 +215,16 @@ async def extract_ingest_resource_id(request: Request) -> Optional[str]: match = re.match(r".*?/collections/([^/]+)$", path) if match and method == "DELETE": collection_id = match.group(1) - resolved_tenant = await _collection_tenant_for_item(request, collection_id) - if resolved_tenant: + tenant = await _collection_tenant_for_item(request, collection_id) + if tenant: + resource_id = STAC_COLLECTION_TEMPLATE.format(tenant) logger.debug( - "Ingest DELETE /collections/%s: resolved tenant=%s (resource_id remains collection:%s)", - collection_id, - resolved_tenant, + "Ingest DELETE /collections/%s: resolved tenant=%s -> %s", collection_id, + tenant, + resource_id, ) - return f"collection:{collection_id}" + return resource_id + return _stac_collection_resource_id(request) return None diff --git a/ingest_api/runtime/src/main.py b/ingest_api/runtime/src/main.py index f6f2cd42..2fc2bad2 100644 --- a/ingest_api/runtime/src/main.py +++ b/ingest_api/runtime/src/main.py @@ -12,7 +12,7 @@ from src.monitoring import ObservabilityMiddleware, logger, metrics, tracer from src.utils import get_keycloak_client_credentials from veda_auth.keycloak_client import KeycloakPDPClient, parse_keycloak_from_openid_url -from veda_auth.pep_middleware import PEPMiddleware +from veda_auth.pep_middleware import INGEST_PROTECTED_ROUTES, PEPMiddleware from veda_auth.resource_extractors import extract_ingest_resource_id from fastapi import Depends, FastAPI, HTTPException, Security @@ -307,6 +307,7 @@ def _get_keycloak_pdp_client() -> KeycloakPDPClient: PEPMiddleware, pdp_client=_get_keycloak_pdp_client, resource_extractor=extract_ingest_resource_id, + protected_routes=INGEST_PROTECTED_ROUTES, ) else: pep_logger.info( From 9ad6e679bcd1951fb644393df3ca6f72e57c3184 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:03:01 -0700 Subject: [PATCH 12/25] fix: update tests --- common/auth/tests/test_pep_middleware.py | 30 +++++++++++++ common/auth/tests/test_resource_extractors.py | 43 +++++++++++++------ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/common/auth/tests/test_pep_middleware.py b/common/auth/tests/test_pep_middleware.py index cd5998a7..13b387a7 100644 --- a/common/auth/tests/test_pep_middleware.py +++ b/common/auth/tests/test_pep_middleware.py @@ -5,6 +5,7 @@ import pytest from veda_auth.pep_middleware import ( DEFAULT_PROTECTED_ROUTES, + INGEST_PROTECTED_ROUTES, STAC_PROTECTED_ROUTES, PEPMiddleware, ) @@ -112,3 +113,32 @@ def test_search_no_match(self, middleware): _request("/api/stac/search", "POST") ) assert result is None + + +class TestIngestProtectedRoutes: + """INGEST_PROTECTED_ROUTES (ingest collection POST and DELETE endpoints)""" + + @pytest.fixture + def middleware(self): + """PEP middleware mock for ingest endpoints""" + app = MagicMock() + return PEPMiddleware( + app, + pdp_client=MagicMock(), + resource_extractor=MagicMock(), + protected_routes=INGEST_PROTECTED_ROUTES, + ) + + def test_post_collections_matches_create(self, middleware): + """POST /collections matches with scope create""" + result = middleware._get_matching_scope_and_route( + _request("/collections", "POST") + ) + assert result == ("create", "POST") + + def test_delete_collection_matches_delete_scope(self, middleware): + """DELETE /collections matches with scope delete""" + result = middleware._get_matching_scope_and_route( + _request("/collections/my-collection", "DELETE") + ) + assert result == ("delete", "DELETE") diff --git a/common/auth/tests/test_resource_extractors.py b/common/auth/tests/test_resource_extractors.py index c9101c5a..f47c9425 100644 --- a/common/auth/tests/test_resource_extractors.py +++ b/common/auth/tests/test_resource_extractors.py @@ -304,8 +304,8 @@ async def test_delete_collection_resolver_none_falls_back_to_url_tenant(self): class TestExtractIngestResourceId: """Test Ingest API resource ID extraction""" - async def test_delete_collection_returns_collection_id(self): - """DELETE /collections/{id} should return collection-specific resource ID""" + async def test_delete_collection_falls_back_to_url_tenant_without_resolver(self): + """DELETE with no resolver uses request.state.tenant when tenant-prefixed path set it""" request = MagicMock(spec=Request) request.url.path = "/collections/test-collection" request.method = "DELETE" @@ -313,20 +313,35 @@ async def test_delete_collection_returns_collection_id(self): request.app = SimpleNamespace(state=SimpleNamespace()) resource_id = await extract_ingest_resource_id(request) - assert resource_id == "collection:test-collection" + assert resource_id == STAC_COLLECTION_TEMPLATE.format("test-tenant") - async def test_delete_collection_without_resolver_still_returns_collection_id(self): - """DELETE without collection_tenant_resolver on app state returns collection:{id}.""" + async def test_delete_collection_without_resolver_or_url_tenant_is_public(self): + """DELETE with no resolver and no state.tenant uses stac:collection:public:*""" request = MagicMock(spec=Request) request.url.path = "/collections/foo" request.method = "DELETE" request.state = SimpleNamespace() request.app = SimpleNamespace(state=SimpleNamespace()) - assert await extract_ingest_resource_id(request) == "collection:foo" + assert await extract_ingest_resource_id(request) == STAC_COLLECTION_PUBLIC - async def test_delete_collection_invokes_resolver_resource_id_unchanged(self): - """When resolver is set, it is awaited; resource id stays collection:{id} (phase 1).""" + async def test_delete_collection_resolver_none_falls_back_to_url_tenant(self): + """When resolver returns None, use request.state.tenant if set (tenant-prefixed paths)""" + resolver = AsyncMock(return_value=None) + request = MagicMock(spec=Request) + request.url.path = "/collections/my-col" + request.method = "DELETE" + request.state = SimpleNamespace(tenant="url-tenant") + request.app = SimpleNamespace( + state=SimpleNamespace(collection_tenant_resolver=resolver) + ) + + assert await extract_ingest_resource_id( + request + ) == STAC_COLLECTION_TEMPLATE.format("url-tenant") + + async def test_delete_collection_uses_resolver_tenant(self): + """DELETE uses resolved tenant (from database lookup) for Keycloak resource ID""" resolver = AsyncMock(return_value="veda") request = MagicMock(spec=Request) request.url.path = "/collections/foo" @@ -337,11 +352,11 @@ async def test_delete_collection_invokes_resolver_resource_id_unchanged(self): ) rid = await extract_ingest_resource_id(request) - assert rid == "collection:foo" + assert rid == STAC_COLLECTION_TEMPLATE.format("veda") resolver.assert_awaited_once_with(request, "foo") - async def test_delete_collection_resolver_raises_still_returns_collection_id(self): - """Resolver failures are swallowed by shared helper; resource id unchanged.""" + async def test_delete_collection_resolver_raises_falls_back_to_public(self): + """Resolver failures fall back to _stac_collection_resource_id (public if no URL tenant)""" resolver = AsyncMock(side_effect=RuntimeError("db unavailable")) request = MagicMock(spec=Request) request.url.path = "/collections/foo" @@ -351,7 +366,7 @@ async def test_delete_collection_resolver_raises_still_returns_collection_id(sel state=SimpleNamespace(collection_tenant_resolver=resolver) ) - assert await extract_ingest_resource_id(request) == "collection:foo" + assert await extract_ingest_resource_id(request) == STAC_COLLECTION_PUBLIC async def test_delete_collection_magicmock_state_resolver_still_works(self): """Resolver runs even when request.state is a MagicMock (no real tenant).""" @@ -364,5 +379,7 @@ async def test_delete_collection_magicmock_state_resolver_still_works(self): state=SimpleNamespace(collection_tenant_resolver=resolver) ) - assert await extract_ingest_resource_id(request) == "collection:bar" + assert await extract_ingest_resource_id( + request + ) == STAC_COLLECTION_TEMPLATE.format("tenant-a") resolver.assert_awaited_once_with(request, "bar") From 64a2a3973a040f31be96bc40deb7bc7abf3b7014 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:03:16 -0700 Subject: [PATCH 13/25] fix: update pep integration tests --- .../runtime/tests/test_pep_integration.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/ingest_api/runtime/tests/test_pep_integration.py b/ingest_api/runtime/tests/test_pep_integration.py index daf69020..98146c7c 100644 --- a/ingest_api/runtime/tests/test_pep_integration.py +++ b/ingest_api/runtime/tests/test_pep_integration.py @@ -198,3 +198,51 @@ def test_post_collection_nonexistent_tenant_returns_404( assert response.status_code == 404 assert "does not exist" in response.json()["detail"] assert "nonexistent-tenant" in response.json()["detail"] + + def test_delete_collection_no_token_returns_401(self, pep_client): + """DELETE /collections/{id} without Bearer is rejected""" + response = pep_client.delete(f"{COLLECTIONS_ENDPOINT}/any-id") + assert response.status_code == 401 + + def test_delete_collection_pep_uses_tenant_resource( + self, pep_client, mock_pdp_client + ): + """DELETE checks PDP for stac:collection:{tenant}:* when collection has a tenant""" + mock_pdp_client.check_permission.return_value = True + collection_id = "pep-delete-tenant-test" + + with patch( + "src.main.collection_publisher.get_collection_tenant", + return_value="veda", + ), patch("src.main.collection_publisher.delete"): + response = pep_client.delete( + f"{COLLECTIONS_ENDPOINT}/{collection_id}", + headers={"Authorization": "Bearer fake-valid-token"}, + ) + + assert response.status_code == 200 + mock_pdp_client.check_permission.assert_called_once() + kwargs = mock_pdp_client.check_permission.call_args.kwargs + assert kwargs.get("resource_id") == "stac:collection:veda:*" + assert kwargs.get("scope") == "delete" + + def test_delete_collection_pep_denied_returns_403( + self, pep_client, mock_pdp_client + ): + """DELETE denied by PDP returns 403""" + mock_pdp_client.check_permission.side_effect = PermissionDeniedError( + resource_id="stac:collection:veda:*", scope="delete" + ) + collection_id = "pep-delete-denied" + + with patch( + "src.main.collection_publisher.get_collection_tenant", + return_value="veda", + ), patch("src.main.collection_publisher.delete"): + response = pep_client.delete( + f"{COLLECTIONS_ENDPOINT}/{collection_id}", + headers={"Authorization": "Bearer fake-valid-token"}, + ) + + assert response.status_code == 403 + assert "do not have permission" in response.json()["detail"] From 5aeac85965cf6a53f37dda3b02dc7fb8a8c7b3da Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:47:14 -0700 Subject: [PATCH 14/25] fix: update readme for ingest delete endpoint description --- common/auth/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/auth/README.md b/common/auth/README.md index 27d24f61..099f625f 100644 --- a/common/auth/README.md +++ b/common/auth/README.md @@ -198,7 +198,9 @@ This section summarizes how the resource extractor functions in `veda_auth.resou | Path pattern | Method | Resource | Tenant source for resource ID | Resource ID returned (shape) | Notes | |------------------------------------|--------|---------------------------|--------------------------------------------------------|-----------------------------------------|-----------------------------------------------------------------------------------------| | `/collections` | `POST` | Create collection request | Request body field `eic:tenant` (or `TENANT_FIELD`), or public | `stac:collection:{tenant}:*` or public | Uses the same body-based extraction helper as the STAC collection write case. | -| `/collections/{collection_id}` | `DELETE` | Delete collection | _none_ (no tenant used) | `collection:{collection_id}` | Ingest delete uses an ID-scoped resource (`collection:{id}`) without tenant component. Tenant-aware deletes will be handled in Phase 2. | +| `/collections/{collection_id}` | `DELETE` | Delete collection | `collection_tenant_resolver`, else URL tenant, else public | `stac:collection:{tenant}:*` or public | Same Keycloak resource shape as STAC; Ingest PEP uses `INGEST_PROTECTED_ROUTES` (POST + DELETE). | + +The Ingest app passes `protected_routes=INGEST_PROTECTED_ROUTES` to `PEPMiddleware` so **POST** `/collections` and **DELETE** `/collections/{collection_id}` both invoke UMA (`extract_ingest_resource_id`). ### See Also From 19e5443e52def79f1e0852a194cfabe14092f591 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:47:33 -0700 Subject: [PATCH 15/25] fix: update ingest api to include tenant filter field in lambda, update lookup method, add to config --- ingest_api/infrastructure/config.py | 4 ++++ ingest_api/infrastructure/construct.py | 2 ++ ingest_api/runtime/src/collection_publisher.py | 3 ++- ingest_api/runtime/src/config.py | 4 ++++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ingest_api/infrastructure/config.py b/ingest_api/infrastructure/config.py index 4a29c676..ee45b073 100644 --- a/ingest_api/infrastructure/config.py +++ b/ingest_api/infrastructure/config.py @@ -95,6 +95,10 @@ class IngestorConfig(BaseSettings): None, description="Name or ARN of the AWS Secrets Manager secret containing Keycloak UMA resource server client_id and client_secret. Use a full ARN for cross-account access.", ) + tenant_filter_field: str = Field( + "eic:tenant", + description="Collection field name used for tenant ownership checks", + ) keycloak_secret_kms_key_arn: Optional[str] = Field( None, diff --git a/ingest_api/infrastructure/construct.py b/ingest_api/infrastructure/construct.py index 5b5562bb..dc06da83 100644 --- a/ingest_api/infrastructure/construct.py +++ b/ingest_api/infrastructure/construct.py @@ -46,6 +46,7 @@ def __init__( "CLIENT_ID": config.keycloak_ingest_api_client_id, "OPENID_CONFIGURATION_URL": str(config.openid_configuration_url), "GIT_SHA": config.git_sha, + "VEDA_TENANT_FILTER_FIELD": config.tenant_filter_field, } if config.keycloak_uma_resource_server_client_secret_name: @@ -261,6 +262,7 @@ def __init__( "CLIENT_ID": config.keycloak_ingest_api_client_id, "OPENID_CONFIGURATION_URL": str(config.openid_configuration_url), "GIT_SHA": config.git_sha, + "VEDA_TENANT_FILTER_FIELD": config.tenant_filter_field, } if config.keycloak_uma_resource_server_client_secret_name: diff --git a/ingest_api/runtime/src/collection_publisher.py b/ingest_api/runtime/src/collection_publisher.py index c4692e94..0e758e74 100644 --- a/ingest_api/runtime/src/collection_publisher.py +++ b/ingest_api/runtime/src/collection_publisher.py @@ -3,6 +3,7 @@ from typing import Any, Dict, Optional from pypgstac.db import PgstacDB +from src.config import settings from src.schemas import DashboardCollection from src.utils import IngestionType, get_db_credentials, load_into_pgstac from src.vedaloader import VEDALoader @@ -36,7 +37,7 @@ def delete(self, collection_id: str): def get_collection_tenant(self, collection_id: str) -> Optional[str]: """Return tenant field from collection JSON in PgSTAC, or None if not found""" - tenant_field = os.getenv("VEDA_TENANT_FILTER_FIELD", "eic:tenant") + tenant_field = settings.tenant_filter_field creds = get_db_credentials(os.environ["DB_SECRET_ARN"]) try: with PgstacDB(dsn=creds.dsn_string, debug=True) as db: diff --git a/ingest_api/runtime/src/config.py b/ingest_api/runtime/src/config.py index f6a59888..99ea17f9 100644 --- a/ingest_api/runtime/src/config.py +++ b/ingest_api/runtime/src/config.py @@ -30,6 +30,10 @@ class Settings(BaseSettings): None, description="Name or ARN of the AWS Secrets Manager secret containing Keycloak UMA resource server client_id and client_secret. Use a full ARN for cross-account access.", ) + tenant_filter_field: str = Field( + "eic:tenant", + description="Collection field name used to resolve tenant ownership." + ) settings = Settings() From 7e1781724e89010772b7fcf64f2ba427d8209272 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:47:57 -0700 Subject: [PATCH 16/25] fix: formatting --- ingest_api/runtime/src/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ingest_api/runtime/src/config.py b/ingest_api/runtime/src/config.py index 99ea17f9..412b5e9e 100644 --- a/ingest_api/runtime/src/config.py +++ b/ingest_api/runtime/src/config.py @@ -32,7 +32,7 @@ class Settings(BaseSettings): ) tenant_filter_field: str = Field( "eic:tenant", - description="Collection field name used to resolve tenant ownership." + description="Collection field name used to resolve tenant ownership.", ) From eaa8435932a8406adfc43ca56def18fd23c3e8ce Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:25:06 -0700 Subject: [PATCH 17/25] fix: add debug logging --- common/auth/veda_auth/resource_extractors.py | 21 +++++++++++++-- .../runtime/src/collection_publisher.py | 27 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/common/auth/veda_auth/resource_extractors.py b/common/auth/veda_auth/resource_extractors.py index 827c992f..8fc07840 100644 --- a/common/auth/veda_auth/resource_extractors.py +++ b/common/auth/veda_auth/resource_extractors.py @@ -70,9 +70,19 @@ async def _collection_tenant_for_item( """Resolve collection tenant for item operations""" resolver = _get_collection_tenant_resolver(request) if not resolver: + logger.debug( + "No collection_tenant_resolver configured on app.state for collection %s", + collection_id, + ) return None try: - return await resolver(request, collection_id) + tenant = await resolver(request, collection_id) + if not tenant: + logger.debug( + "collection_tenant_resolver returned no tenant for collection %s", + collection_id, + ) + return tenant except Exception as e: logger.warning( "Failed to resolve collection tenant for item ops %s: %s", @@ -225,6 +235,13 @@ async def extract_ingest_resource_id(request: Request) -> Optional[str]: resource_id, ) return resource_id - return _stac_collection_resource_id(request) + fallback_resource_id = _stac_collection_resource_id(request) + logger.info( + "Ingest DELETE /collections/%s: falling back to resource_id=%s (resolver_none_or_missing), path=%s", + collection_id, + fallback_resource_id, + path, + ) + return fallback_resource_id return None diff --git a/ingest_api/runtime/src/collection_publisher.py b/ingest_api/runtime/src/collection_publisher.py index 0e758e74..4316bc56 100644 --- a/ingest_api/runtime/src/collection_publisher.py +++ b/ingest_api/runtime/src/collection_publisher.py @@ -38,6 +38,11 @@ def delete(self, collection_id: str): def get_collection_tenant(self, collection_id: str) -> Optional[str]: """Return tenant field from collection JSON in PgSTAC, or None if not found""" tenant_field = settings.tenant_filter_field + logger.debug( + "Resolving tenant for collection %s using tenant_field=%s", + collection_id, + tenant_field, + ) creds = get_db_credentials(os.environ["DB_SECRET_ARN"]) try: with PgstacDB(dsn=creds.dsn_string, debug=True) as db: @@ -45,14 +50,32 @@ def get_collection_tenant(self, collection_id: str) -> Optional[str]: base_item: Dict[str, Any] base_item, _, _ = loader.collection_json(collection_id) except Exception: - logger.debug( - "Could not load collection %s for tenant lookup", + logger.warning( + "Could not load collection %s for tenant lookup (tenant_field=%s)", collection_id, + tenant_field, ) return None if not isinstance(base_item, dict): + logger.debug( + "Collection %s payload is not a dict during tenant lookup", + collection_id, + ) return None val = base_item.get(tenant_field) + if not val: + logger.debug( + "Collection %s has no value for tenant_field=%s", + collection_id, + tenant_field, + ) + return None + logger.info( + "Resolved tenant for collection %s: tenant_field=%s tenant=%s", + collection_id, + tenant_field, + val, + ) return str(val) if val else None From 98906eb1047987ab5292db187f1d6e1c640feebf Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:54:01 -0700 Subject: [PATCH 18/25] fix: add more logging --- common/auth/veda_auth/resource_extractors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/common/auth/veda_auth/resource_extractors.py b/common/auth/veda_auth/resource_extractors.py index 8fc07840..ab7907cf 100644 --- a/common/auth/veda_auth/resource_extractors.py +++ b/common/auth/veda_auth/resource_extractors.py @@ -88,6 +88,7 @@ async def _collection_tenant_for_item( "Failed to resolve collection tenant for item ops %s: %s", collection_id, e, + exc_info=True, ) return None From 469c7f451d46460f50930a7ba9a5ad0d866b0b56 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:19:16 -0700 Subject: [PATCH 19/25] fix: try alternative collection lookup and add validation alias to config --- .../runtime/src/collection_publisher.py | 41 +++++++++++++++---- ingest_api/runtime/src/config.py | 1 + 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/ingest_api/runtime/src/collection_publisher.py b/ingest_api/runtime/src/collection_publisher.py index 4316bc56..bc664b32 100644 --- a/ingest_api/runtime/src/collection_publisher.py +++ b/ingest_api/runtime/src/collection_publisher.py @@ -49,6 +49,10 @@ def get_collection_tenant(self, collection_id: str) -> Optional[str]: loader = VEDALoader(db=db) base_item: Dict[str, Any] base_item, _, _ = loader.collection_json(collection_id) + collection_content = db.query_one( + "SELECT content FROM collections WHERE id=%s", + (collection_id,), + ) except Exception: logger.warning( "Could not load collection %s for tenant lookup (tenant_field=%s)", @@ -61,22 +65,41 @@ def get_collection_tenant(self, collection_id: str) -> Optional[str]: "Collection %s payload is not a dict during tenant lookup", collection_id, ) - return None + base_item = {} val = base_item.get(tenant_field) + if val: + logger.info( + "Resolved tenant for collection %s: tenant_field=%s tenant=%s source=base_item", + collection_id, + tenant_field, + val, + ) + return str(val) + + content_dict = None + if isinstance(collection_content, tuple) and collection_content: + content_dict = collection_content[0] + elif isinstance(collection_content, dict): + content_dict = collection_content + if isinstance(content_dict, dict): + val = content_dict.get(tenant_field) + if val: + logger.info( + "Resolved tenant for collection %s: tenant_field=%s tenant=%s source=content", + collection_id, + tenant_field, + val, + ) + return str(val) + if not val: logger.debug( - "Collection %s has no value for tenant_field=%s", + "Collection %s has no value for tenant_field=%s in base_item/content", collection_id, tenant_field, ) return None - logger.info( - "Resolved tenant for collection %s: tenant_field=%s tenant=%s", - collection_id, - tenant_field, - val, - ) - return str(val) if val else None + return str(val) class ItemPublisher: diff --git a/ingest_api/runtime/src/config.py b/ingest_api/runtime/src/config.py index 412b5e9e..a30c4796 100644 --- a/ingest_api/runtime/src/config.py +++ b/ingest_api/runtime/src/config.py @@ -32,6 +32,7 @@ class Settings(BaseSettings): ) tenant_filter_field: str = Field( "eic:tenant", + validation_alias="VEDA_TENANT_FILTER_FIELD", description="Collection field name used to resolve tenant ownership.", ) From 8391d897612a877f0f8a1300301b5ba863205d02 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:12:21 -0700 Subject: [PATCH 20/25] fix: add logging to determine content shape --- .../runtime/src/collection_publisher.py | 52 ++++++++----------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/ingest_api/runtime/src/collection_publisher.py b/ingest_api/runtime/src/collection_publisher.py index bc664b32..5fa44dbc 100644 --- a/ingest_api/runtime/src/collection_publisher.py +++ b/ingest_api/runtime/src/collection_publisher.py @@ -46,9 +46,6 @@ def get_collection_tenant(self, collection_id: str) -> Optional[str]: creds = get_db_credentials(os.environ["DB_SECRET_ARN"]) try: with PgstacDB(dsn=creds.dsn_string, debug=True) as db: - loader = VEDALoader(db=db) - base_item: Dict[str, Any] - base_item, _, _ = loader.collection_json(collection_id) collection_content = db.query_one( "SELECT content FROM collections WHERE id=%s", (collection_id,), @@ -60,46 +57,43 @@ def get_collection_tenant(self, collection_id: str) -> Optional[str]: tenant_field, ) return None - if not isinstance(base_item, dict): - logger.debug( - "Collection %s payload is not a dict during tenant lookup", - collection_id, - ) - base_item = {} - val = base_item.get(tenant_field) - if val: - logger.info( - "Resolved tenant for collection %s: tenant_field=%s tenant=%s source=base_item", - collection_id, - tenant_field, - val, - ) - return str(val) - - content_dict = None + logger.debug( + "collection_content shape for %s is collection_content_type=%s", + collection_id, + type(collection_content).__name__, + ) + content_dict: Optional[Dict[str, Any]] = None if isinstance(collection_content, tuple) and collection_content: content_dict = collection_content[0] elif isinstance(collection_content, dict): content_dict = collection_content + logger.debug( + "collection_content normalized for %s: normalized_is_dict=%s tenant_field_present=%s", + collection_id, + isinstance(content_dict, dict), + isinstance(content_dict, dict) and tenant_field in content_dict, + ) if isinstance(content_dict, dict): - val = content_dict.get(tenant_field) - if val: + tenant_value = content_dict.get(tenant_field) + if tenant_value: logger.info( - "Resolved tenant for collection %s: tenant_field=%s tenant=%s source=content", + "Resolved tenant for collection %s: tenant_field=%s tenant=%s source=content(canonical)", collection_id, tenant_field, - val, + tenant_value, ) - return str(val) - - if not val: + return str(tenant_value) logger.debug( - "Collection %s has no value for tenant_field=%s in base_item/content", + "Collection %s has no value for tenant_field=%s in content", collection_id, tenant_field, ) return None - return str(val) + logger.debug( + "Collection %s content payload is not a dict during tenant lookup", + collection_id, + ) + return None class ItemPublisher: From 1e13f3fd11e825ed6091626282e0a8108907b953 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:38:57 -0700 Subject: [PATCH 21/25] fix: update log level --- ingest_api/runtime/src/collection_publisher.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ingest_api/runtime/src/collection_publisher.py b/ingest_api/runtime/src/collection_publisher.py index 5fa44dbc..abc85be5 100644 --- a/ingest_api/runtime/src/collection_publisher.py +++ b/ingest_api/runtime/src/collection_publisher.py @@ -38,7 +38,7 @@ def delete(self, collection_id: str): def get_collection_tenant(self, collection_id: str) -> Optional[str]: """Return tenant field from collection JSON in PgSTAC, or None if not found""" tenant_field = settings.tenant_filter_field - logger.debug( + logger.info( "Resolving tenant for collection %s using tenant_field=%s", collection_id, tenant_field, @@ -57,7 +57,7 @@ def get_collection_tenant(self, collection_id: str) -> Optional[str]: tenant_field, ) return None - logger.debug( + logger.info( "collection_content shape for %s is collection_content_type=%s", collection_id, type(collection_content).__name__, @@ -67,7 +67,7 @@ def get_collection_tenant(self, collection_id: str) -> Optional[str]: content_dict = collection_content[0] elif isinstance(collection_content, dict): content_dict = collection_content - logger.debug( + logger.info( "collection_content normalized for %s: normalized_is_dict=%s tenant_field_present=%s", collection_id, isinstance(content_dict, dict), @@ -83,13 +83,13 @@ def get_collection_tenant(self, collection_id: str) -> Optional[str]: tenant_value, ) return str(tenant_value) - logger.debug( + logger.info( "Collection %s has no value for tenant_field=%s in content", collection_id, tenant_field, ) return None - logger.debug( + logger.info( "Collection %s content payload is not a dict during tenant lookup", collection_id, ) From 3e63e03921551397bb244d79f3fa5eaf176673a3 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:04:25 -0700 Subject: [PATCH 22/25] fix: update logs and remove tuple, use dict only --- .../runtime/src/collection_publisher.py | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/ingest_api/runtime/src/collection_publisher.py b/ingest_api/runtime/src/collection_publisher.py index abc85be5..20695247 100644 --- a/ingest_api/runtime/src/collection_publisher.py +++ b/ingest_api/runtime/src/collection_publisher.py @@ -62,36 +62,34 @@ def get_collection_tenant(self, collection_id: str) -> Optional[str]: collection_id, type(collection_content).__name__, ) - content_dict: Optional[Dict[str, Any]] = None - if isinstance(collection_content, tuple) and collection_content: - content_dict = collection_content[0] - elif isinstance(collection_content, dict): - content_dict = collection_content + content_dict = collection_content + if not isinstance(content_dict, dict): + logger.info( + "Collection %s content payload is not a dict during tenant lookup", + collection_id, + ) + return None + logger.info( - "collection_content normalized for %s: normalized_is_dict=%s tenant_field_present=%s", + "collection_content tenant_field_present for %s: tenant_field_present=%s", collection_id, - isinstance(content_dict, dict), - isinstance(content_dict, dict) and tenant_field in content_dict, + tenant_field in content_dict, ) - if isinstance(content_dict, dict): - tenant_value = content_dict.get(tenant_field) - if tenant_value: - logger.info( - "Resolved tenant for collection %s: tenant_field=%s tenant=%s source=content(canonical)", - collection_id, - tenant_field, - tenant_value, - ) - return str(tenant_value) + + tenant_value = content_dict.get(tenant_field) + if tenant_value: logger.info( - "Collection %s has no value for tenant_field=%s in content", + "Resolved tenant for collection %s: tenant_field=%s tenant=%s source=content(canonical)", collection_id, tenant_field, + tenant_value, ) - return None + return str(tenant_value) + logger.info( - "Collection %s content payload is not a dict during tenant lookup", + "Collection %s has no value for tenant_field=%s in content", collection_id, + tenant_field, ) return None From 470e5edd1155a87f9aa7f06e013d419130e6a4d5 Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:06:30 -0700 Subject: [PATCH 23/25] fix: linting --- ingest_api/runtime/src/collection_publisher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ingest_api/runtime/src/collection_publisher.py b/ingest_api/runtime/src/collection_publisher.py index 20695247..e6db65ab 100644 --- a/ingest_api/runtime/src/collection_publisher.py +++ b/ingest_api/runtime/src/collection_publisher.py @@ -1,6 +1,6 @@ import logging import os -from typing import Any, Dict, Optional +from typing import Optional from pypgstac.db import PgstacDB from src.config import settings From 4281ed096d17553ebca3fa843631ba9731b7379a Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:38:33 -0700 Subject: [PATCH 24/25] fix: simplify logging --- ingest_api/runtime/src/collection_publisher.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/ingest_api/runtime/src/collection_publisher.py b/ingest_api/runtime/src/collection_publisher.py index e6db65ab..8c9e1c3b 100644 --- a/ingest_api/runtime/src/collection_publisher.py +++ b/ingest_api/runtime/src/collection_publisher.py @@ -38,11 +38,6 @@ def delete(self, collection_id: str): def get_collection_tenant(self, collection_id: str) -> Optional[str]: """Return tenant field from collection JSON in PgSTAC, or None if not found""" tenant_field = settings.tenant_filter_field - logger.info( - "Resolving tenant for collection %s using tenant_field=%s", - collection_id, - tenant_field, - ) creds = get_db_credentials(os.environ["DB_SECRET_ARN"]) try: with PgstacDB(dsn=creds.dsn_string, debug=True) as db: @@ -57,11 +52,6 @@ def get_collection_tenant(self, collection_id: str) -> Optional[str]: tenant_field, ) return None - logger.info( - "collection_content shape for %s is collection_content_type=%s", - collection_id, - type(collection_content).__name__, - ) content_dict = collection_content if not isinstance(content_dict, dict): logger.info( @@ -79,7 +69,7 @@ def get_collection_tenant(self, collection_id: str) -> Optional[str]: tenant_value = content_dict.get(tenant_field) if tenant_value: logger.info( - "Resolved tenant for collection %s: tenant_field=%s tenant=%s source=content(canonical)", + "Resolved tenant for collection %s: tenant_field=%s tenant=%s", collection_id, tenant_field, tenant_value, From 60b529e3ff426ff97a9c8de1c513c1be34297dbc Mon Sep 17 00:00:00 2001 From: Jennifer Tran <12633533+botanical@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:01:00 -0700 Subject: [PATCH 25/25] fix: remove unused method param, consolidate items and bulk items path logic --- common/auth/veda_auth/resource_extractors.py | 31 +++++++------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/common/auth/veda_auth/resource_extractors.py b/common/auth/veda_auth/resource_extractors.py index ab7907cf..d8e1eb44 100644 --- a/common/auth/veda_auth/resource_extractors.py +++ b/common/auth/veda_auth/resource_extractors.py @@ -155,10 +155,8 @@ async def _extract_collection_stac_resource_id( return None -async def _extract_item_stac_resource_id( - request: Request, path: str, method: str -) -> Optional[str]: - """Extract resource ID for item endpoints, or None if not an item path""" +async def _extract_item_stac_resource_id(request: Request, path: str) -> Optional[str]: + """Extract resource ID for item endpoints based on path, or None""" if _COLLECTIONS_ITEM_PATH_PATTERN.match(path): # For single item operations, prefer collection tenant when available match = _COLLECTIONS_ITEM_PATH_PATTERN.match(path) @@ -169,24 +167,15 @@ async def _extract_item_stac_resource_id( return STAC_ITEM_TEMPLATE.format(tenant) return _stac_item_resource_id(request) - if _COLLECTIONS_ITEMS_PATH_PATTERN.match(path): - # use collection tenant when available, otherwise collection/public - match = _COLLECTIONS_ITEMS_PATH_PATTERN.match(path) - collection_id = match.group(1) if match else None - if collection_id: - tenant = await _collection_tenant_for_item(request, collection_id) - if tenant: - return STAC_ITEM_TEMPLATE.format(tenant) - return _stac_collection_resource_id(request) - - if _COLLECTIONS_BULK_ITEMS_PATH_PATTERN.match(path): - match = _COLLECTIONS_BULK_ITEMS_PATH_PATTERN.match(path) - collection_id = match.group(1) if match else None - if collection_id: - tenant = await _collection_tenant_for_item(request, collection_id) + for pattern in ( + _COLLECTIONS_ITEMS_PATH_PATTERN, + _COLLECTIONS_BULK_ITEMS_PATH_PATTERN, + ): + if match := pattern.match(path): + tenant = await _collection_tenant_for_item(request, match.group(1)) if tenant: return STAC_ITEM_TEMPLATE.format(tenant) - return _stac_collection_resource_id(request) + return _stac_collection_resource_id(request) return None @@ -205,7 +194,7 @@ async def extract_stac_resource_id(request: Request) -> Optional[str]: if collection_id is not None: return collection_id - item_id = await _extract_item_stac_resource_id(request, path, method) + item_id = await _extract_item_stac_resource_id(request, path) if item_id is not None: return item_id