Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4760fe4
fix: wip retrieve tenant from collection
botanical Mar 13, 2026
583c1d2
fix: break up function, fix linting errors
botanical Mar 13, 2026
c62415b
fix: add some tests
botanical Mar 16, 2026
fad4908
fix: update tests to use item from conftest
botanical Mar 18, 2026
62e052b
fix: move resolver outside of lifespan
botanical Mar 18, 2026
793e74b
fix: update resource extractors to check for tenant strings:
botanical Mar 18, 2026
fb8dd98
feat: wip extend to ingest api
botanical Mar 24, 2026
59626e6
Merge branch 'develop' into mt-uma/pep-items
botanical Mar 24, 2026
ec9a8eb
Merge branch 'mt-uma/pep-items' of https://github.com/NASA-IMPACT/ved…
botanical Mar 24, 2026
ea3a243
fix: linting and set tenant resolver always
botanical Mar 24, 2026
2f862dc
fix: fix template response
botanical Mar 24, 2026
3367a16
Merge branch 'develop' into mt-uma/pep-items
botanical Mar 24, 2026
205ae6b
fix: extend tenant lookup resolver to stac delete endpoint
botanical Mar 24, 2026
45cf990
feat: extend pep to delete ingest endpoint
botanical Mar 24, 2026
9ad6e67
fix: update tests
botanical Mar 25, 2026
64a2a39
fix: update pep integration tests
botanical Mar 25, 2026
5aeac85
fix: update readme for ingest delete endpoint description
botanical Mar 25, 2026
19e5443
fix: update ingest api to include tenant filter field in lambda, upda…
botanical Mar 25, 2026
7e17817
fix: formatting
botanical Mar 25, 2026
eaa8435
fix: add debug logging
botanical Mar 25, 2026
98906eb
fix: add more logging
botanical Mar 25, 2026
469c7f4
fix: try alternative collection lookup and add validation alias to co…
botanical Mar 25, 2026
8391d89
fix: add logging to determine content shape
botanical Mar 25, 2026
1e13f3f
fix: update log level
botanical Mar 25, 2026
3e63e03
fix: update logs and remove tuple, use dict only
botanical Mar 25, 2026
470e5ed
fix: linting
botanical Mar 25, 2026
4281ed0
fix: simplify logging
botanical Mar 25, 2026
60b529e
fix: remove unused method param, consolidate items and bulk items pat…
botanical Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions common/auth/tests/test_resource_extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"""
Expand All @@ -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"""
Expand Down
25 changes: 24 additions & 1 deletion common/auth/veda_auth/pep_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
109 changes: 94 additions & 15 deletions common/auth/veda_auth/resource_extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -38,13 +40,46 @@
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(
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(
Expand Down Expand Up @@ -87,15 +122,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)

Expand All @@ -104,14 +134,63 @@ 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is method being used?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😯 good call out! Let me remove it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

) -> 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)
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)
Comment thread
sandrahoang686 marked this conversation as resolved.
Outdated

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

Expand Down
27 changes: 26 additions & 1 deletion stac_api/runtime/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

from contextlib import asynccontextmanager
from typing import Optional

from aws_lambda_powertools.metrics import MetricUnit
from src.config import (
Expand Down Expand Up @@ -94,6 +95,25 @@ 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"""
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(
Expand Down Expand Up @@ -142,6 +162,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."""
Expand Down Expand Up @@ -179,13 +203,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 STAC_PROTECTED_ROUTES, PEPMiddleware
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(
Expand Down
Loading
Loading