Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion common/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions common/auth/tests/test_pep_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest
from veda_auth.pep_middleware import (
DEFAULT_PROTECTED_ROUTES,
INGEST_PROTECTED_ROUTES,
STAC_PROTECTED_ROUTES,
PEPMiddleware,
)
Expand Down Expand Up @@ -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")
180 changes: 177 additions & 3 deletions common/auth/tests/test_resource_extractors.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Tests for resource extractors"""

import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest
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 +176,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,16 +209,177 @@ 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

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"""

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"
request.state.tenant = "test-tenant"
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_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) == STAC_COLLECTION_PUBLIC

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"
request.method = "DELETE"
request.state = SimpleNamespace()
request.app = SimpleNamespace(
state=SimpleNamespace(collection_tenant_resolver=resolver)
)

rid = await extract_ingest_resource_id(request)
assert rid == STAC_COLLECTION_TEMPLATE.format("veda")
resolver.assert_awaited_once_with(request, "foo")

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"
request.method = "DELETE"
request.state = SimpleNamespace()
request.app = SimpleNamespace(
state=SimpleNamespace(collection_tenant_resolver=resolver)
)

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)."""
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
) == STAC_COLLECTION_TEMPLATE.format("tenant-a")
resolver.assert_awaited_once_with(request, "bar")
17 changes: 17 additions & 0 deletions common/auth/veda_auth/pep_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
TokenError,
)
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,
)

Expand Down Expand Up @@ -46,12 +49,26 @@ 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"),
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"
),
)


Expand Down
Loading
Loading