Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
c57c14e
Update poetry.
rhysrevans3 May 14, 2026
a738f2d
Bump pydantic version for python 3.14.
rhysrevans3 May 14, 2026
aa79dd1
Updating default extensions.
rhysrevans3 May 19, 2026
efada20
Merge branch 'main' of https://github.com/rhysrevans3/stac-transactio…
rhysrevans3 May 19, 2026
df14767
Add geometry validation.
rhysrevans3 Jun 3, 2026
f8bd704
Merge branch 'main' of github.com:ESGF/stac-transaction-api into dev
rhysrevans3 Jun 4, 2026
fd5959a
Add shapely dependency.
rhysrevans3 Jun 4, 2026
74e6b8e
Update extensions.
rhysrevans3 Jun 4, 2026
e12e415
pre-commit.
rhysrevans3 Jun 4, 2026
b79d66b
Fix regex.
rhysrevans3 Jun 4, 2026
bd35864
Add WGS84 bound box validation.
rhysrevans3 Jun 10, 2026
c66864e
pre-commit.
rhysrevans3 Jun 10, 2026
2da6244
Simplify bbox if.
rhysrevans3 Jun 10, 2026
2ee127f
Add minimum version check for STAC extensions.
rhysrevans3 Jun 18, 2026
7b9a612
pre-commit.
rhysrevans3 Jun 18, 2026
71537c8
Move version regex to settings.
rhysrevans3 Jun 19, 2026
58a59ee
Add scope skip to EGI authorizer.
rhysrevans3 Jun 22, 2026
a7996f3
Add scope url for EGI auth.
rhysrevans3 Jun 22, 2026
608af98
Make scope a setting.
rhysrevans3 Jun 23, 2026
c79b824
Update imports and stac-fastapi version.
rhysrevans3 Jun 23, 2026
ab6f679
Update import.
rhysrevans3 Jun 23, 2026
c7f7831
Update logging for egi authorizer.
rhysrevans3 Jun 23, 2026
93097d6
Set logging level in api.
rhysrevans3 Jun 23, 2026
119da87
Add EGI audience check.
rhysrevans3 Jun 23, 2026
6858116
Add healthcheck log filtering.
rhysrevans3 Jun 23, 2026
168c978
Update to use common Authorizer for EGIAuthorizer.
rhysrevans3 Jun 24, 2026
a98a3a8
Remove unneeded event and request id from validation.
rhysrevans3 Jun 24, 2026
81e8d2d
EGIAuth to Authorizer.
rhysrevans3 Jun 24, 2026
ae08ad4
pre-commit.
rhysrevans3 Jun 24, 2026
024e460
Bump version.
rhysrevans3 Jun 24, 2026
5e6a37f
pre-commit.
rhysrevans3 Jun 24, 2026
2c1fda2
Bump core utils version.
rhysrevans3 Jun 24, 2026
678d3ac
Move token info log.
rhysrevans3 Jun 24, 2026
8e7c1d9
Compare host to audience hostnames.
rhysrevans3 Jun 24, 2026
5ef1cc8
Add InvalidTokenAudienceException handler.
rhysrevans3 Jun 24, 2026
76d97e3
Correct version order.
rhysrevans3 Jun 24, 2026
10caf7c
Add ExtensionBelowMinimumException to RFC9457Exception exceptions.
rhysrevans3 Jun 24, 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
1,337 changes: 819 additions & 518 deletions poetry.lock

Large diffs are not rendered by default.

14 changes: 9 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
[project]
name = "stac-transaction-api"
requires-python = ">=3.12,<4.0"
description = "West ESGF STAC Transaction API"
version = "0.1.0"
description = "ESGF STAC Transaction API"
version = "0.2.0"
authors = [
{"name" = "Lukasz Lacinski", "email" = "lukasz@uchicago.edu"},
{"name" = "Steve Turoscy", "email" = "sturoscy@uchicago.edu"},
]
readme = "README.md"
dependencies = [
"esgf-core-utils>=1.0.7",
"esgf-core-utils>=1.1.0",
"fastapi>=0.114.0",
"jsonschema>=4.24.0",
"stac-fastapi.types>=6.0.0",
"stac-fastapi.extensions>=6.0.0",
"packaging>=26.2",
"shapely>=2.1.2",
"stac-fastapi.types>=6.3.0",
"stac-fastapi.extensions>=6.3.0",
"uvicorn>=0.46.0",
]

[dependency-groups]
ceda = [
"httpx>=0.28.1",
"httpx-auth>=0.23.1",
"confluent-kafka>=2.12.1",
"pydantic>=2.12"
]
globus = [
"boto3==1.43.6",
Expand Down
50 changes: 47 additions & 3 deletions src/api.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
from esgf_core_utils.models.exceptions import RFC9457Exception
import logging
import uuid

from esgf_core_utils.models.exceptions import (
InvalidTokenAudienceException,
RFC9457Exception,
)
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from stac_fastapi.extensions.core.transaction import TransactionExtension
from stac_fastapi.extensions import TransactionExtension
from stac_fastapi.types.config import ApiSettings

from authorizer import Authorizer
from client import TransactionClient
from settings import settings

logger = logging.getLogger("uvicorn.error")
logger.setLevel(logging.DEBUG if settings.debug else logging.INFO)


class HealthCheckFilter(logging.Filter):
def filter(self, record):
return "/healthcheck" not in record.getMessage()


logging.getLogger("uvicorn.access").addFilter(HealthCheckFilter())

app = FastAPI(debug=settings.debug)


Expand All @@ -21,6 +38,17 @@ async def healthcheck():
)


if settings.authorizer == "egi":

@app.get("/scope")
async def scope():
return JSONResponse(
content={"scope": settings.client.scope},
media_type="application/json",
status_code=200,
)


@app.exception_handler(RFC9457Exception)
async def rfc9457_handler(request: Request, exc: RFC9457Exception):
return JSONResponse(
Expand All @@ -35,11 +63,27 @@ async def rfc9457_handler(request: Request, exc: RFC9457Exception):
)


@app.exception_handler(InvalidTokenAudienceException)
async def invalid_token_audience_handler(request: Request, exc: InvalidTokenAudienceException):
event_id = uuid.uuid4().hex
request_id = request.headers.get("x-request-id", uuid.uuid4().hex)
return JSONResponse(
status_code=exc.status_code,
content={
"status_code": exc.status_code,
"type": exc.type,
"title": exc.title,
"detail": exc.detail,
"instance": f"{request_id}:{event_id}",
},
)


core_client = TransactionClient()

api_settings = ApiSettings(
api_title="STAC Transaction API",
api_version="0.1.0",
api_version="0.2.0",
openapi_url="/openapi.json",
)

Expand Down
20 changes: 15 additions & 5 deletions src/authorizer/egi_authorizer.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import logging
from urllib.parse import urlparse

import httpx
from esgf_core_utils.models.auth.egi import EGIAuth
from esgf_core_utils.models.auth import Authorizer
from esgf_core_utils.models.exceptions import InvalidTokenAudienceException
from esgf_core_utils.models.kafka.events import RequesterData
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
Expand All @@ -28,18 +30,18 @@ class EGIAuthorizer(BaseHTTPMiddleware):

async def dispatch(self, request: Request, call_next):
# Need to bypass authorization for this endpoint
if request.url.path == "/healthcheck":
if request.url.path in ["/healthcheck", "/scope"]:
return await call_next(request)

logger.info("Request Headers %s", request.headers)
logger.debug("Request Headers %s", request.headers)

auth = httpx.BasicAuth(
username=settings.client.client_id,
password=settings.client.client_secret,
)

async with httpx.AsyncClient(timeout=5.0, verify=False) as client:
logger.info(
logger.debug(
"Post request to %s",
settings.client.introspection_endpoint,
)
Expand All @@ -54,7 +56,15 @@ async def dispatch(self, request: Request, call_next):

token_info = response.json()

authorizer = EGIAuth(
logger.debug("Token info: %s", token_info)

if request.headers["host"] not in [urlparse(aud).hostname for aud in token_info["aud"]]:
raise InvalidTokenAudienceException(
token_audience=request.headers["host"],
expected_audience=", ".join(token_info["aud"]),
)

authorizer = Authorizer(
regex=settings.client.regex,
requester_data=RequesterData(
client_id=token_info["client_id"],
Expand Down
45 changes: 29 additions & 16 deletions src/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
from datetime import datetime
from typing import Optional, Union

from esgf_core_utils.models.auth.egi import EGIAuth
from esgf_core_utils.models.auth import Authorizer
from esgf_core_utils.models.exceptions import (
AuthorizationException,
ExpectedExtensionsMissingException,
ExtensionBelowMinimumException,
MissingPermissionException,
OperationNotPermittedException,
RFC9457Exception,
Expand All @@ -27,8 +28,8 @@
)
from esgf_core_utils.models.kafka.producer import KafkaProducer
from fastapi import Request, Response, status
from stac_fastapi.extensions.core.transaction import BaseTransactionsClient
from stac_fastapi.extensions.core.transaction.request import PartialItem, PatchOperation
from stac_fastapi.extensions.transaction import BaseTransactionsClient
from stac_fastapi.extensions.transaction.request import PartialItem, PatchOperation
from stac_fastapi.types.stac import Collection
from stac_pydantic.item import Item

Expand All @@ -43,6 +44,7 @@
# Setup logger
# logger = logging.getLogger(__name__)
logger = logging.getLogger("uvicorn.error")
logger.setLevel(logging.DEBUG)


class TransactionClient(BaseTransactionsClient):
Expand Down Expand Up @@ -125,7 +127,7 @@ def egi_authorize(
Returns:
Auth: Auth object if successful
"""
authorizer: EGIAuth = request.state.authorizer
authorizer: Authorizer = request.state.authorizer
authorizer.authorize(
collection_id=collection_id,
item=item,
Expand Down Expand Up @@ -191,8 +193,6 @@ async def create_item(
try:
item_extensions = validate_extensions(collection_id=collection_id, item_extensions=item_extensions)
validate_post(
event_id=event_id,
request_id=request_id,
item_id=item.id,
item=item,
extensions=item_extensions,
Expand All @@ -203,6 +203,7 @@ async def create_item(
OperationNotPermittedException,
STACValidationException,
UnexpectedExtensionException,
ExtensionBelowMinimumException,
) as exc:
rfc_exc = RFC9457Exception()
rfc_exc.status_code = 400
Expand All @@ -211,6 +212,7 @@ async def create_item(
rfc_exc.detail = exc.detail
rfc_exc.instance = f"{request_id}:{event_id}"
raise rfc_exc from exc

user_agent = headers.get("user-agent", "/").split("/")

payload = CreatePayload(
Expand Down Expand Up @@ -275,8 +277,6 @@ async def update_item(
item_extensions = validate_extensions(collection_id=collection_id, item_extensions=item_extensions)

validate_post(
event_id=event_id,
request_id=request_id,
item_id=item.id,
item=item,
extensions=item_extensions,
Expand Down Expand Up @@ -345,16 +345,29 @@ async def patch_item(
)

item_extensions = item.stac_extensions if item.stac_extensions else []
try:

item_extensions = validate_extensions(collection_id=collection_id, item_extensions=item_extensions)
item_extensions = validate_extensions(collection_id=collection_id, item_extensions=item_extensions)

validate_patch(
event_id=event_id,
request_id=request_id,
item_id=item_id,
item=item,
extensions=item_extensions,
)
validate_patch(
item_id=item_id,
item=item,
extensions=item_extensions,
)
except (
ExpectedExtensionsMissingException,
OperationNotPermittedException,
STACValidationException,
UnexpectedExtensionException,
ExtensionBelowMinimumException,
) as exc:
rfc_exc = RFC9457Exception()
rfc_exc.status_code = 400
rfc_exc.type = exc.type
rfc_exc.title = exc.title
rfc_exc.detail = exc.detail
rfc_exc.instance = f"{request_id}:{event_id}"
raise rfc_exc from exc

user_agent = headers.get("user-agent", "/").split("/")

Expand Down
45 changes: 28 additions & 17 deletions src/settings/__init__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import os
from typing import Literal

import re
from pydantic_settings import BaseSettings, SettingsConfigDict

if os.environ.get("TRANSACTION_AUTHORIZER") == "egi":
from settings.ceda import CEDAClientSettings as ClientSettings
else:
from settings.globus import GlobusClientSettings as ClientSettings

DEFAULT_EXTENSIONS = {
"obs4MIPs": {
"obs4MIPs": {
"regex": [r"https:\/\/esgf\.github\.io\/stac-transaction-api\/obs4mips\/v[0-9]\.[0-9]\.[0-9]/schema\.json"],
"default": "https://esgf.github.io/stac-transaction-api/obs4mips/v1.0.0/schema.json",
"CMIP6": {
"CMIP6": {
"regex": [r"https:\/\/esgf\.github\.io\/stac-transaction-api\/cmip6\/v[0-9]\.[0-9]\.[0-9]/schema\.json"],
"default": "https://esgf.github.io/stac-transaction-api/cmip6/v3.0.4/schema.json",
},
"alternate_assets": {
"regex": [r"https:\/\/stac-extensions\.github\.io\/alternate-assets\/v[0-9]\.[0-9]\.[0-9]\/schema\.json"],
Expand All @@ -22,10 +23,10 @@
"default": "https://stac-extensions.github.io/file/v2.1.0/schema.json",
},
},
"CMIP6": {
"CMIP6": {
"regex": [r"https:\/\/esgf\.github\.io\/stac-transaction-api\/cmip6\/v[0-9]\.[0-9]\.[0-9]/schema\.json"],
"default": "https://esgf.github.io/stac-transaction-api/cmip6/v3.0.4/schema.json",
"CMIP6Plus": {
"CMIP6Plus": {
"regex": [r"https:\/\/esgf\.github\.io\/stac-transaction-api\/cmip6plus\/v[0-9]\.[0-9]\.[0-9]/schema\.json"],
"default": "https://esgf.github.io/stac-transaction-api/cmip6plus/v1.0.4/schema.json",
},
"alternate_assets": {
"regex": [r"https:\/\/stac-extensions\.github\.io\/alternate-assets\/v[0-9]\.[0-9]\.[0-9]\/schema\.json"],
Expand All @@ -36,10 +37,10 @@
"default": "https://stac-extensions.github.io/file/v2.1.0/schema.json",
},
},
"CMIP6Plus": {
"CMIP6Plus": {
"regex": [r"https:\/\/esgf\.github\.io\/stac-transaction-api\/cmip6plus\/v[0-9]\.[0-9]\.[0-9]/schema\.json"],
"default": "https://esgf.github.io/stac-transaction-api/cmip6plus/v1.0.4/schema.json",
"CMIP7": {
"CMIP7": {
"regex": [r"https:\/\/esgf\.github\.io\/stac-transaction-api\/cmip7\/v[0-9]\.[0-9]\.[0-9]\/schema\.json"],
"default": "https://esgf.github.io/stac-transaction-api/cmip7/v1.0.0/schema.json",
},
"alternate_assets": {
"regex": [r"https:\/\/stac-extensions\.github\.io\/alternate-assets\/v[0-9]\.[0-9]\.[0-9]\/schema\.json"],
Expand All @@ -64,10 +65,10 @@
"default": "https://stac-extensions.github.io/file/v2.1.0/schema.json",
},
},
"CMIP7": {
"CMIP7": {
"regex": [r"https:\/\/esgf\.github\.io\/stac-transaction-api\/cmip7\/v[0-9]\.[0-9]\.[0-9]\/schema\.json"],
"default": "https://esgf.github.io/stac-transaction-api/cmip7/v1.0.0/schema.json",
"obs4MIPs": {
"obs4MIPs": {
"regex": [r"https:\/\/esgf\.github\.io\/stac-transaction-api\/obs4mips\/v[0-9]\.[0-9]\.[0-9]/schema\.json"],
"default": "https://esgf.github.io/stac-transaction-api/obs4mips/v1.0.0/schema.json",
},
"alternate_assets": {
"regex": [r"https:\/\/stac-extensions\.github\.io\/alternate-assets\/v[0-9]\.[0-9]\.[0-9]\/schema\.json"],
Expand All @@ -80,6 +81,16 @@
},
}

VERSION_REGEX = re.compile(
r"/v("
r"(?P<major>0|[1-9]\d*)\."
r"(?P<minor>0|[1-9]\d*)\."
r"(?P<patch>0|[1-9]\d*)"
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
r")/"
)


class Settings(BaseSettings):
"""
Expand Down
1 change: 1 addition & 0 deletions src/settings/ceda.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ class CEDAClientSettings(BaseModel):
r"urn\:mace\:egi\.eu\:group\:esgf.vo.egi.eu\:(?P<type>[^:]*)\:(?P<id>[^:]*)"
r"(\:institution\:(?P<institution>[^:]*))?\:role=(?P<role>[^:]*)#aai\.egi\.eu"
)
scope: str = "offline_access entitlements"
Loading
Loading