Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
66d464b
fix: move parse_keycloak_from_openid_url to common/auth package to reuse
botanical Feb 12, 2026
a17407f
fix: linting and generalize regexes to use in pep middleware
botanical Feb 13, 2026
921095c
feat: create policy enforcement point for stac api
botanical Feb 13, 2026
2d96128
feat: add pep middleware to stac api
botanical Feb 13, 2026
9d08bef
fix: import common auth in dockerfile
botanical Feb 13, 2026
9e5a724
fix: attempt to resolve dependencies
botanical Feb 13, 2026
0629819
fix: add debugging
botanical Feb 13, 2026
283f9ab
fix: env vars for lambda
botanical Feb 13, 2026
605a881
fix: update to use keycloak secret instead
botanical Feb 13, 2026
bb514b4
fix: add prefix to keycloak secret env var
botanical Feb 13, 2026
e4c4a9d
fix: update error message, and also update keycloak client to check r…
botanical Feb 13, 2026
b0a0342
fix: catch invalid token case and throw helpful error
botanical Feb 13, 2026
38bc05f
fix: move pep middleware to common auth so it's reusable
botanical Feb 14, 2026
c64d035
fix: add pep middleware to ingest api, delete old middleware file in …
botanical Feb 14, 2026
bec19a6
fix: formatting
botanical Feb 14, 2026
3fa37e1
feat: add integration tests for proof of concept
botanical Feb 17, 2026
cd79d46
fix: attempt to fix mutable mapping incompatible type issue
botanical Feb 17, 2026
266818c
fix: attempt to fix tests, and enable transactions on pep int tests
botanical Feb 18, 2026
480d5bb
fix: add logging to conftest to debug
botanical Feb 18, 2026
e7c004f
fix: linting
botanical Feb 18, 2026
aaf029a
fix: clear cache and reload config and app to properly register endpoint
botanical Feb 18, 2026
e4f0113
fix: add root path to pep int tests
botanical Feb 18, 2026
e360ced
fix: add back arg comment
botanical Feb 18, 2026
a6e4f60
Update common/auth/veda_auth/keycloak_client.py
botanical Feb 19, 2026
5c0b8c1
fix: add path to error message, and add error message function for pe…
botanical Feb 19, 2026
b2380c2
fix: add case to handle nonexistent tenant
botanical Mar 2, 2026
9b033e0
fix: attempt to fix tests
botanical Mar 2, 2026
2176ff9
fix: create new error for permission denied
botanical Mar 6, 2026
601fdc1
Merge branch 'develop' into mt-uma/pep-poc
botanical Mar 6, 2026
31c8873
fix: remove return type on check_permission
botanical Mar 6, 2026
a8a9a55
fix: formatting, remove unused variable
botanical Mar 6, 2026
6721ff0
fix: update readme
botanical Mar 6, 2026
afdc19a
fix: update tests
botanical Mar 6, 2026
243a793
fix: spacing, move import
botanical Mar 9, 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
22 changes: 16 additions & 6 deletions common/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,26 @@ The scope is defined by the resource server. To see the definition for veda, che

**Returns:**

- `bool`: `True` if permission granted, `False` otherwise
- `bool`: `True` if permission granted

**Raises:**

- `TokenError`: Access token expired or invalid (401)
- `PermissionDeniedError`: User lacks permission for the resource/scope (403)
- `ResourceNotFoundError`: Resource (tenant) does not exist (400 invalid_resource)

**Example:**

```python
can_create = pdp_client.check_permission(
access_token=token,
resource_id="collection:my-tenant",
scope="create"
)
try:
pdp_client.check_permission(
access_token=token,
resource_id="collection:my-tenant",
scope="create"
)
# permission granted
except PermissionDeniedError:
# permission denied
```

#### `get_rpt(access_token, resources)`
Expand Down
159 changes: 126 additions & 33 deletions common/auth/veda_auth/keycloak_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,75 @@
import base64
import json
import logging
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlencode
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import urlencode, urlparse

import httpx

logger = logging.getLogger(__name__)


class TokenError(Exception):
"""Raised when the access token is expired, revoked, or invalid.
Keycloak returns HTTP 401
"""

def __init__(self, detail: str = "Access token is expired or invalid"):
"""To use when there is a token error for RPT call"""
self.detail = detail
super().__init__(detail)


class ResourceNotFoundError(Exception):
"""Raised when Keycloak returns HTTP 400 with invalid_resource error.
This means the requested resource (tenant) does not exist in the
resource server
"""

def __init__(self, resource_id: str):
"""Initialize with the resource ID that was not found"""
self.resource_id = resource_id
super().__init__(f"Resource not found: {resource_id}")


class PermissionDeniedError(Exception):
"""Raised when Keycloak returns HTTP 403 forbidden which means
the user does not have permission for the requested resource and scope.
"""

def __init__(self, resource_id: str, scope: Optional[str] = None):
"""Initialize with the resource ID and optional scope that was denied"""
self.resource_id = resource_id
self.scope = scope
super().__init__(f"Permission denied: {resource_id}")


def parse_keycloak_from_openid_url(
openid_configuration_url: Union[str, Any]
) -> Tuple[str, str]:
"""Extract Keycloak base URL and realm from an OpenID discovery URL such as https://<host>/realms/<realm>/.well-known/openid-configuration"""
if not openid_configuration_url:
raise ValueError("Missing or empty OpenID configuration URL")
url_str = str(openid_configuration_url).strip()

parsed = urlparse(url_str)
path = (parsed.path or "").rstrip("/")

if "/realms/" not in path:
raise ValueError(
"OpenID configuration URL must contain /realms/<realm>/ "
"(e.g. .../realms/my-realm/.well-known/openid-configuration). "
f"Got path: {repr(path)}"
)

realm = path.split("/realms/")[-1].split("/")[0]
if not realm:
raise ValueError("Could not extract realm from OpenID configuration URL")

keycloak_url = f"{parsed.scheme}://{parsed.netloc}"
return keycloak_url, realm


def _add_base64_padding(payload: str) -> str:
"""Add padding to base64 string if needed

Expand Down Expand Up @@ -157,12 +218,71 @@ def _extract_permissions_from_jwt(self, jwt_token: str) -> List[Dict[str, Any]]:
logger.warning(f"Failed to extract permissions from JWT: {e}")
return []

def _resolve_permissions(
self, rpt_response: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Extract permissions from an RPT response, and fall back to the JWT"""
permissions = rpt_response.get("permissions", [])
if not permissions:
rpt_jwt = rpt_response.get("access_token")
if rpt_jwt:
permissions = self._extract_permissions_from_jwt(rpt_jwt)
logger.debug(f"Extracted {len(permissions)} permissions from RPT JWT")
return permissions

def _has_matching_permission(
self,
permissions: List[Dict[str, Any]],
resource_id: str,
scope: str,
) -> bool:
"""Return True if permissions contain a grant for resource_id and scope

See https://www.keycloak.org/docs/latest/authorization_services/#_service_rpt_overview
"""
for permission in permissions:
rsname = permission.get("rsname") or permission.get("resource_id")
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm unclear on why rsid isn't relevant to check anymore

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah that's a good question! Basically we're not using rsid because the RPT puts the name in rsname (e.g. "stac:item:faketenant2:*") and the UUID in rsid (and sometimes in resource_id). You can check this yourself by requesting an RPT and introspecting it but basically it will show something like

{
            "scopes": [
                "read"
            ],
            "rsid": "some-uuid",
            "rsname": "stac:item:faketenant2:*",
            "resource_id": "some-uuid",
            "resource_scopes": [
                "read"
            ]
        }

We need the name to match and to parse type/tenant, so we use rsname (and resource_id when it’s the name). rsid only ever gives us the UUID which we don't use. I guess really we don't need resource_id but I had that as a fallback because I wasn't sure where keycloak would put the resource name.

if rsname == resource_id and scope in permission.get("scopes", []):
return True
return False

def _handle_rpt_http_error(
self,
error: httpx.HTTPStatusError,
resource_id: str,
scope: Optional[str] = None,
) -> None:
"""Translate an HTTPStatusError from get_rpt

Raises TokenError for 401, PermissionDeniedError for 403,
ResourceNotFoundError for 400 invalid_resource.
Re-raises unhandled status codes.
"""
if error.response.status_code == 401:
logger.warning("Token rejected (401): %s", error.response.text)
raise TokenError(
"Access token is expired or invalid. Please re-authenticate."
) from error
if error.response.status_code == 403:
raise PermissionDeniedError(resource_id=resource_id, scope=scope) from error
if error.response.status_code == 400:
try:
error_body = error.response.json()
except Exception:
error_body = {}
if error_body.get("error") == "invalid_resource":
raise ResourceNotFoundError(resource_id=resource_id) from error
logger.error(
f"Permission check failed: {error.response.status_code} {error.response.text}"
)
raise error

def check_permission(
self,
access_token: str,
resource_id: str,
scope: str,
) -> bool:
):
"""Check if user has permission for a resource and scope

Args:
Expand All @@ -183,37 +303,10 @@ def check_permission(
}
],
)

permissions = rpt_response.get("permissions", [])
if not permissions:
rpt_jwt = rpt_response.get("access_token")
if rpt_jwt:
permissions = self._extract_permissions_from_jwt(rpt_jwt)
logger.debug(
f"Extracted {len(permissions)} permissions from RPT JWT"
)

# https://www.keycloak.org/docs/latest/authorization_services/#_service_rpt_overview
for permission in permissions:
# Check rsid (RPT token format), resource_id (introspection format), or rsname (resource name)
resource_identifier = (
permission.get("rsid")
or permission.get("resource_id")
or permission.get("rsname")
)
if resource_identifier == resource_id:
scopes = permission.get("scopes", [])
if scope in scopes:
return True

return False
permissions = self._resolve_permissions(rpt_response)
return self._has_matching_permission(permissions, resource_id, scope)
except httpx.HTTPStatusError as e:
if e.response.status_code in (401, 403):
return False
logger.error(
f"Permission check failed: {e.response.status_code} {e.response.text}"
)
raise
self._handle_rpt_http_error(e, resource_id, scope=scope)
except Exception as e:
logger.error(f"Unexpected error checking permission: {e}")
raise
Expand Down
Loading