Skip to content

Commit 5f331f3

Browse files
authored
feat(stac): add policy enforcement point to all create/update stac endpoints (#571)
### Issue #559 ### What? - Updates to extend PEP to STAC endpoints for collections where the action is a create or update - This takes a very naive approach to apply the policy enforcement point by relying on the request body's tenant value to determine permissions ### Testing? - Unit and Integration tests updated | Test Case | Expected Result | Actual Result | |--|--|--| | User in tenant1 is able to update a collection in tenant1 | Allow | Allow| | User in tenant1 and tenant2 is able to update a collection from tenant1 -> tenant2 | Allow | Allow| | User in tenant1 and tenant2 and **not** tenant3 is not able to update a collection from tenant1 -> tenant3 | Deny | Deny | | User in tenant1 _only_ attempts to update a collection from tenant1 -> tenant2 | Deny |  Deny | | User in tenant2 _only_ attempts to update a collection from tenant1 -> tenant2 | Allow |  Allow | | User in tenant1 and tenant2 and not tenant3 attempts to update a collection from tenant1 → tenant3 | Deny |  Deny | | User in tenant1 and tenant3 updates a collection from tenant1 -> tenant3 | Allow |  Allow | | User in tenant1 and tenant2 updates a collection from tenant2 -> tenant1 | Allow |  Allow | | User with no tenant memberships updates a public collection (public -> public) | Allow |  Allow | | User in tenant1 updates a public collection to tenant1 (public -> tenant1) | Allow |  Allow | | User in tenant1 attempts to update a collection in tenant1 to public (tenant1 -> public) | Allow |  Allow | https://sit.openveda.cloud/
2 parents 1f45cb4 + 42e65c6 commit 5f331f3

File tree

4 files changed

+245
-3
lines changed

4 files changed

+245
-3
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Unit tests for PEP middleware route matching"""
2+
3+
from unittest.mock import MagicMock
4+
5+
import pytest
6+
from veda_auth.pep_middleware import (
7+
DEFAULT_PROTECTED_ROUTES,
8+
STAC_PROTECTED_ROUTES,
9+
PEPMiddleware,
10+
)
11+
12+
13+
def _request(path: str, method: str = "GET"):
14+
"""Request mock for route matching"""
15+
req = MagicMock()
16+
req.url.path = path.rstrip("/") or "/"
17+
req.method = method.upper()
18+
return req
19+
20+
21+
class TestDefaultProtectedRoutes:
22+
"""DEFAULT_PROTECTED_ROUTES tests"""
23+
24+
def test_post_collections_matches(self):
25+
"""POST collections should return create and POST"""
26+
app = MagicMock()
27+
middleware = PEPMiddleware(
28+
app,
29+
pdp_client=MagicMock(),
30+
resource_extractor=MagicMock(),
31+
protected_routes=DEFAULT_PROTECTED_ROUTES,
32+
)
33+
result = middleware._get_matching_scope_and_route(
34+
_request("/collections", "POST")
35+
)
36+
assert result == ("create", "POST")
37+
38+
def test_put_collections_no_match(self):
39+
"""PUT /collections/{id} does not match DEFAULT_PROTECTED_ROUTES so it returns None"""
40+
app = MagicMock()
41+
middleware = PEPMiddleware(
42+
app,
43+
pdp_client=MagicMock(),
44+
resource_extractor=MagicMock(),
45+
protected_routes=DEFAULT_PROTECTED_ROUTES,
46+
)
47+
result = middleware._get_matching_scope_and_route(
48+
_request("/collections/random", "PUT")
49+
)
50+
assert result is None
51+
52+
def test_get_collections_no_match(self):
53+
"""GET on collections should return None"""
54+
app = MagicMock()
55+
middleware = PEPMiddleware(
56+
app,
57+
pdp_client=MagicMock(),
58+
resource_extractor=MagicMock(),
59+
protected_routes=DEFAULT_PROTECTED_ROUTES,
60+
)
61+
result = middleware._get_matching_scope_and_route(
62+
_request("/collections", "GET")
63+
)
64+
assert result is None
65+
66+
67+
class TestStacProtectedRoutes:
68+
"""STAC_PROTECTED_ROUTES (all collection and item write operations)"""
69+
70+
@pytest.fixture
71+
def middleware(self):
72+
"""PEP Middlware mock"""
73+
app = MagicMock()
74+
return PEPMiddleware(
75+
app,
76+
pdp_client=MagicMock(),
77+
resource_extractor=MagicMock(),
78+
protected_routes=STAC_PROTECTED_ROUTES,
79+
)
80+
81+
def test_post_collections_matches_create(self, middleware):
82+
"""POST /collections matches with scope create"""
83+
result = middleware._get_matching_scope_and_route(
84+
_request("/api/stac/collections", "POST")
85+
)
86+
assert result == ("create", "POST")
87+
88+
def test_put_collection_matches_update(self, middleware):
89+
"""PUT /collections/{id} matches with scope update"""
90+
result = middleware._get_matching_scope_and_route(
91+
_request("/api/stac/collections/some-collection", "PUT")
92+
)
93+
assert result == ("update", "PUT")
94+
95+
def test_patch_collection_matches_update(self, middleware):
96+
"""PATCH /collections/{id} matches with scope update"""
97+
result = middleware._get_matching_scope_and_route(
98+
_request("/api/stac/collections/some-collection", "PATCH")
99+
)
100+
assert result == ("update", "PATCH")
101+
102+
def test_get_collections_no_match(self, middleware):
103+
"""GET /collections does not match so it returns None"""
104+
result = middleware._get_matching_scope_and_route(
105+
_request("/api/stac/collections", "GET")
106+
)
107+
assert result is None
108+
109+
def test_search_no_match(self, middleware):
110+
"""POST /search does not match so it returns None"""
111+
result = middleware._get_matching_scope_and_route(
112+
_request("/api/stac/search", "POST")
113+
)
114+
assert result is None

common/auth/veda_auth/pep_middleware.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
ResourceNotFoundError,
1212
TokenError,
1313
)
14-
from veda_auth.resource_extractors import COLLECTIONS_CREATE_PATH_RE
14+
from veda_auth.resource_extractors import (
15+
COLLECTIONS_CREATE_PATH_RE,
16+
COLLECTIONS_PATH_RE,
17+
)
1518

1619
from starlette.middleware.base import BaseHTTPMiddleware
1720
from starlette.requests import Request
@@ -35,8 +38,20 @@ class ProtectedRoute:
3538
scope: str
3639

3740

38-
DEFAULT_PROTECTED_ROUTES: Sequence[ProtectedRoute] = (
41+
CREATE_COLLECTION_ROUTE = ProtectedRoute(
42+
path_re=COLLECTIONS_CREATE_PATH_RE,
43+
method="POST",
44+
scope="create",
45+
)
46+
47+
DEFAULT_PROTECTED_ROUTES: Sequence[ProtectedRoute] = (CREATE_COLLECTION_ROUTE,)
48+
49+
50+
STAC_PROTECTED_ROUTES: Sequence[ProtectedRoute] = (
51+
# Collections
3952
ProtectedRoute(path_re=COLLECTIONS_CREATE_PATH_RE, method="POST", scope="create"),
53+
ProtectedRoute(path_re=COLLECTIONS_PATH_RE, method="PUT", scope="update"),
54+
ProtectedRoute(path_re=COLLECTIONS_PATH_RE, method="PATCH", scope="update"),
4055
)
4156

4257

stac_api/runtime/src/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ async def lifespan(app: FastAPI):
5656
postgres_settings=api_settings.postgres_settings,
5757
add_write_connection_pool=True,
5858
)
59+
5960
yield
6061
await close_db_connection(app)
6162

@@ -179,13 +180,14 @@ def _get_keycloak_pdp_client():
179180
"PEP middleware enabled, secret_name=%s",
180181
api_settings.keycloak_uma_resource_server_client_secret_name,
181182
)
182-
from veda_auth.pep_middleware import PEPMiddleware
183+
from veda_auth.pep_middleware import STAC_PROTECTED_ROUTES, PEPMiddleware
183184
from veda_auth.resource_extractors import extract_stac_resource_id
184185

185186
app.add_middleware(
186187
PEPMiddleware,
187188
pdp_client=_get_keycloak_pdp_client,
188189
resource_extractor=extract_stac_resource_id,
190+
protected_routes=STAC_PROTECTED_ROUTES,
189191
)
190192
else:
191193
logger.info(

stac_api/runtime/tests/test_pep_integration.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Integration tests for PEP middleware"""
2+
import copy
23
import importlib
34
import os
45
import uuid
@@ -13,6 +14,8 @@
1314

1415
from stac_fastapi.pgstac.db import close_db_connection, connect_to_db
1516

17+
from .conftest import VALID_ITEM
18+
1619
VALID_COLLECTION_TEMPLATE = {
1720
"type": "Collection",
1821
"title": "Test Collection for PEP",
@@ -105,6 +108,18 @@ def _collection(tenant: Optional[str] = None) -> dict:
105108
return body
106109

107110

111+
def _item(collection_id: str, item_id: Optional[str] = None) -> dict:
112+
"""Build a STAC item"""
113+
item_id_value = item_id or f"pep-item-{uuid.uuid4().hex[:8]}"
114+
item = copy.deepcopy(VALID_ITEM)
115+
item["id"] = item_id_value
116+
item["collection"] = collection_id
117+
return item
118+
119+
120+
AUTH_HEADERS = {"Authorization": "Bearer fake-valid-token"}
121+
122+
108123
class TestPEPIntegration:
109124
"""Integration tests for PEP middleware for POST /collections endpoint"""
110125

@@ -243,3 +258,99 @@ async def test_get_collections_not_affected_by_pep(self, pep_client):
243258
"""GET /collections should not be intercepted by PEP (because its not a protected route)"""
244259
response = await pep_client.get(COLLECTIONS_ENDPOINT)
245260
assert response.status_code == 200
261+
262+
263+
class TestPEPCollectionUpdateDelete:
264+
"""PEP for PUT/PATCH/DELETE collection"""
265+
266+
@pytest.mark.asyncio
267+
async def test_put_collection_no_token_returns_401(
268+
self, pep_client, mock_pdp_client
269+
):
270+
"""PUT /collections/{id} without token returns 401"""
271+
collection = _collection()
272+
# Create collection, then try to update it without token
273+
await pep_client.post(
274+
COLLECTIONS_ENDPOINT, json=collection, headers=AUTH_HEADERS
275+
)
276+
response = await pep_client.put(
277+
f"{COLLECTIONS_ENDPOINT}/{collection['id']}",
278+
json=collection,
279+
)
280+
# Should fail with 401
281+
assert response.status_code == 401
282+
# Cleanup
283+
await pep_client.delete(
284+
f"{COLLECTIONS_ENDPOINT}/{collection['id']}", headers=AUTH_HEADERS
285+
)
286+
287+
@pytest.mark.asyncio
288+
async def test_put_collection_authorized_succeeds(
289+
self, pep_client, mock_pdp_client
290+
):
291+
"""PUT /collections/{id} with token and PDP allow succeeds, scope update"""
292+
# Test setup
293+
mock_pdp_client.check_permission.return_value = True
294+
collection = _collection()
295+
await pep_client.post(
296+
COLLECTIONS_ENDPOINT, json=collection, headers=AUTH_HEADERS
297+
)
298+
response = await pep_client.put(
299+
f"{COLLECTIONS_ENDPOINT}/{collection['id']}",
300+
json=collection,
301+
headers=AUTH_HEADERS,
302+
)
303+
assert response.status_code == 200
304+
call_kwargs = mock_pdp_client.check_permission.call_args
305+
assert call_kwargs.kwargs.get("scope") == "update"
306+
307+
# Test cleanup
308+
await pep_client.delete(
309+
f"{COLLECTIONS_ENDPOINT}/{collection['id']}", headers=AUTH_HEADERS
310+
)
311+
312+
@pytest.mark.asyncio
313+
async def test_patch_collection_no_token_returns_401(
314+
self, pep_client, mock_pdp_client
315+
):
316+
"""PATCH /collections/{id} without token returns 401"""
317+
# Test setup
318+
collection = _collection()
319+
await pep_client.post(
320+
COLLECTIONS_ENDPOINT, json=collection, headers=AUTH_HEADERS
321+
)
322+
response = await pep_client.patch(
323+
f"{COLLECTIONS_ENDPOINT}/{collection['id']}",
324+
json={"description": "Updated"},
325+
)
326+
assert response.status_code == 401
327+
328+
# Test cleanup
329+
await pep_client.delete(
330+
f"{COLLECTIONS_ENDPOINT}/{collection['id']}", headers=AUTH_HEADERS
331+
)
332+
333+
@pytest.mark.asyncio
334+
async def test_patch_collection_authorized_succeeds(
335+
self, pep_client, mock_pdp_client
336+
):
337+
"""PATCH /collections/{id} with token succeeds, scope update"""
338+
# Test setup
339+
mock_pdp_client.check_permission.return_value = True
340+
collection = _collection()
341+
await pep_client.post(
342+
COLLECTIONS_ENDPOINT, json=collection, headers=AUTH_HEADERS
343+
)
344+
response = await pep_client.patch(
345+
f"{COLLECTIONS_ENDPOINT}/{collection['id']}",
346+
json={"description": "Updated"},
347+
headers=AUTH_HEADERS,
348+
)
349+
assert response.status_code == 200
350+
call_kwargs = mock_pdp_client.check_permission.call_args
351+
assert call_kwargs.kwargs.get("scope") == "update"
352+
353+
# Test cleanup
354+
await pep_client.delete(
355+
f"{COLLECTIONS_ENDPOINT}/{collection['id']}", headers=AUTH_HEADERS
356+
)

0 commit comments

Comments
 (0)