Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
env:
MONGODB_URI: mongodb://localhost:27017
MARBLE_API_MONGODB_URI: mongodb://localhost:27017
jobs:
test:
if: github.event.pull_request.draft == false
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.13.2
rev: v0.15.9
hooks:
# Run the linter.
- id: ruff
- id: ruff-check
# Run the formatter.
- id: ruff-format

35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,44 @@ An API for the Marble platform.

## Authentication and Authorization

Marble API does not do any authentication or authorization (authn/z). That is left to other
applications (such as [Magpie](https://github.com/ouranosinc/magpie)).
Marble API uses [Magpie](https://github.com/ouranosinc/magpie) for authentication.

Marble API assumes that only users with administrator access should be able to access all routes
prefixed with `/vX/admin/` (where `X` is a version number).
Authentication is enforced for the following routes:

Marble API also assumes that only users with a given user name or id `Y` should be able to access
all routes prefixed with `/vX/users/Y/` (where `X` is a version number).
- admin routes: `/vX/admin/` (where `X` is a version number)
- user routes: `/vX/users/Y/` (where `X` is a version number and `Y` is a user name)

Only users who belong to the group named "administrators" in Magpie will have access to
the admin routes. Only users whose Magpie user name matches the `Y` in user routes will
have access to the given user route.

Authn/z can be configured with the following environment variables:

- `MARBLE_API_MAGPIE_AUTH_ENABLED`
- default: `True`
- type: boolean
- set to `False` to disable authentication entirely (this is not recommended in a production environment)
- `MARBLE_API_MAGPIE_URL`
- default: `None`
- type: string (URL format)
- set to the URL for the Magpie instance used to authenticate users
- `MARBLE_API_MAGPIE_ADMIN_GROUP`
- default: `administrators`
- type: string
- change this if you want a different Magpie group to be have access to the admin routes

When integrating Marble API with the [birdhouse](https://github.com/bird-house/birdhouse-deploy/) platform we
recommend enabling it with the
[Marble API component](https://github.com/DACCS-Climate/marble-config/tree/main/components/marble-api).
This enables the basic authn/z rules described above through [Magpie](https://github.com/ouranosinc/magpie).
This sets default environment variables that will work with most birdhouse deployments.

## Developing

To start a development server:

```sh
python3 -m pip install .[dev]
MONGODB_URI="mongodb://localhost:27017" fastapi dev marble_api
MARBLE_API_MONGODB_URI="mongodb://localhost:27017" fastapi dev marble_api
```

This assumes that you have a mongodb service running at `mongodb://localhost:27017`.
Expand Down Expand Up @@ -86,7 +103,7 @@ To run tests:

```sh
python3 -m pip install .[dev]
MONGODB_URI="mongodb://localhost:27017" pytest ./test
MARBLE_API_MONGODB_URI="mongodb://localhost:27017" pytest ./test
```

This assumes that you have a mongodb service running at `mongodb://localhost:27017`.
Expand Down
10 changes: 8 additions & 2 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@ services:
image: python:3.13-alpine
volumes:
- .:/app
- marble-api-venv:/marble-api/venv
working_dir: /app
command: ["sh", "-c", "pip install -e .[dev,test] && fastapi dev marble_api --host 0.0.0.0"]
command: ["sh", "-c", "python -m venv /marble-api/venv && pip install -e .[dev,test] && fastapi dev marble_api --host 0.0.0.0"]
environment:
- MONGODB_URI=mongodb://mongo:27017
- PATH=/marble-api/venv/bin:/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
- MARBLE_API_MONGODB_URI=mongodb://mongo:27017
- MARBLE_API_MAGPIE_AUTH_ENABLED=false
ports:
- 8000:8000
mongo:
image: mongo:5.0.4

volumes:
marble-api-venv:
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ services:
marble_api:
image: marbleclimate/marble-api:latest
environment:
- MONGODB_URI=mongodb://mongo:27017
- MARBLE_API_MONGODB_URI=mongodb://mongo:27017
ports:
- 8000:8000
mongo:
Expand Down
24 changes: 24 additions & 0 deletions marble_api/_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from typing import Self

from pydantic import HttpUrl, MongoDsn, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Config(BaseSettings):
mongodb_uri: MongoDsn
magpie_auth_enabled: bool = True
magpie_url: HttpUrl | None = None
magpie_admin_group: str = "administrators"

model_config = SettingsConfigDict(env_prefix="marble_api_")

@model_validator(mode="after")
def require_auth_settings(self) -> Self:
if self.magpie_auth_enabled:
not_set = [field for field, value in self if field.startswith("magpie_") and not value]
if not_set:
raise ValueError(f"The following fields are required if 'magpie_auth_enabled' is set: {not_set}")
return self


config = Config()
2 changes: 1 addition & 1 deletion marble_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from fastapi import FastAPI, Request

from marble_api.versions.v1.app import router as v1_router
from marble_api.versions.v1 import router as v1_router

_metadata = metadata.metadata("marble_api").json

Expand Down
6 changes: 3 additions & 3 deletions marble_api/database/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import os

from pymongo import AsyncMongoClient
from pymongo.asynchronous.database import AsyncDatabase

from marble_api._config import config


class Client(AsyncMongoClient):
"""AsyncMongoClient with different defaults."""
Expand All @@ -17,4 +17,4 @@ def db(self) -> AsyncDatabase:
return self.get_default_database()


client = Client(os.environ["MONGODB_URI"], tz_aware=True)
client = Client(str(config.mongodb_uri), tz_aware=True)
47 changes: 47 additions & 0 deletions marble_api/utils/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from collections.abc import Callable
from functools import wraps
from typing import Any

import httpx
from fastapi import HTTPException, Request

from marble_api._config import config


def _if_auth_enabled(func: Callable) -> Callable:
"""Only run function if magpie authentication is enabled."""

@wraps(func)
async def _(*args, **kwargs) -> Any: # noqa: ANN401
if config.magpie_auth_enabled:
return await func(*args, **kwargs)

return _


async def _get_authenticated_magpie_user_session(cookies: dict[str, str]) -> dict[str, Any]:
async with httpx.AsyncClient(cookies=cookies) as client:
try:
response = await client.get(f"{config.magpie_url}/session")
except httpx.HTTPError:
raise HTTPException(status_code=403, detail="Forbidden: unable to authenticate")
json = response.json()
if response.status_code == 200 and json["authenticated"]:
return json
raise HTTPException(status_code=403, detail="Forbidden")


@_if_auth_enabled
async def authenticate_magpie_user(request: Request, user: str) -> None:
"""Raise exception if the user's username does not match the request path."""
magpie_session = await _get_authenticated_magpie_user_session(request.cookies)
if magpie_session.get("user", {}).get("user_name") != user:
raise HTTPException(status_code=403, detail="Forbidden")


@_if_auth_enabled
async def authenticate_magpie_admin(request: Request) -> None:
"""Raise exception if the user is not part of the admin group."""
magpie_session = await _get_authenticated_magpie_user_session(request.cookies)
if config.magpie_admin_group not in magpie_session.get("user", {}).get("group_names", []):
raise HTTPException(status_code=403, detail="Forbidden")
16 changes: 16 additions & 0 deletions marble_api/versions/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends

from marble_api.utils.auth import authenticate_magpie_admin, authenticate_magpie_user
from marble_api.versions.v1.data_request.routes import admin_router as data_request_admin_router
from marble_api.versions.v1.data_request.routes import user_router as data_request_user_router

router = APIRouter(prefix="/v1")

user_router = APIRouter(prefix="/users/{user}", tags=["User"], dependencies=[Depends(authenticate_magpie_user)])
admin_router = APIRouter(prefix="/admin", tags=["Admin"], dependencies=[Depends(authenticate_magpie_admin)])

user_router.include_router(data_request_user_router)
admin_router.include_router(data_request_admin_router)

router.include_router(user_router)
router.include_router(admin_router)
9 changes: 0 additions & 9 deletions marble_api/versions/v1/app.py

This file was deleted.

37 changes: 16 additions & 21 deletions marble_api/versions/v1/data_request/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,14 @@ async def _handle_serialization_error() -> AsyncGenerator[None]:
raise HTTPException(status_code=422, detail=str(e)) from e


user_router = APIRouter(prefix="/users/{user}/data-requests", tags=["User"])
admin_router = APIRouter(
prefix="/admin/data-requests", tags=["Admin"], dependencies=[Depends(_handle_serialization_error)]
)
user_router = APIRouter(prefix="/data-requests")
admin_router = APIRouter(prefix="/data-requests", dependencies=[Depends(_handle_serialization_error)])


def _data_request_id(id_: str) -> ObjectId:
return object_id(id_, HTTPException(status_code=404, detail=f"data publish request with id={id_} not found"))


def _is_router_scope(request: Request, router: APIRouter) -> bool:
return request.scope.get("route").path.startswith(f"{router.prefix}/")


@user_router.post("/")
@admin_router.post("/")
async def post_data_request_user(user: str, data_request: DataRequest) -> DataRequestPublic:
Expand All @@ -51,18 +45,21 @@ async def post_data_request_user(user: str, data_request: DataRequest) -> DataRe
return new_data_request


@user_router.patch("/{request_id}")
def _check_user_change(data_request: DataRequestUpdate, user: str | None = None) -> None:
"""Users cannot change the data request so that it belongs to a different user."""
updated_fields = data_request.model_dump(exclude_unset=True, by_alias=True)
if updated_fields.get("user") and user != updated_fields.get("user"):
raise HTTPException(status_code=403, detail="Forbidden")


@user_router.patch("/{request_id}", dependencies=[Depends(_check_user_change)])
@admin_router.patch("/{request_id}")
async def patch_data_request(
request_id: str, data_request: DataRequestUpdate, request: Request, user: str | None = None
request_id: str, data_request: DataRequestUpdate, user: str | None = None
) -> DataRequestPublic:
"""Update fields of data request and return the updated data request."""
updated_fields = data_request.model_dump(exclude_unset=True, by_alias=True)
updated_user = updated_fields.get("user")
if updated_user and _is_router_scope(request, user_router) and user != updated_user:
# Users cannot change the data request so that it belongs to a different user
raise HTTPException(status_code=403, detail="Forbidden")
if user:
if user is not None:
data_request.user = user
selector = {"_id": _data_request_id(request_id)}
# updated timestamps are handled automatically
Expand All @@ -82,12 +79,10 @@ async def patch_data_request(

@user_router.get("/{request_id}", response_model_by_alias=False)
@admin_router.get("/{request_id}", response_model_by_alias=False)
async def get_data_request(
request_id: str, request: Request, stac: bool = False, user: str | None = None
) -> DataRequestPublic:
async def get_data_request(request_id: str, stac: bool = False, user: str | None = None) -> DataRequestPublic:
"""Get a data request with the given request_id."""
selector = {"_id": _data_request_id(request_id)}
if _is_router_scope(request, user_router):
if user is not None:
selector["user"] = user
if (result := await client.db["data-request"].find_one(selector)) is not None:
if stac:
Expand All @@ -105,7 +100,7 @@ async def get_data_request(
async def delete_data_request(request_id: str, request: Request, user: str | None = None) -> Response:
"""Delete a data request with the given request_id."""
selector = {"_id": _data_request_id(request_id)}
if _is_router_scope(request, user_router):
if user is not None:
selector["user"] = user

result = await client.db["data-request"].delete_one(selector)
Expand Down Expand Up @@ -139,7 +134,7 @@ async def get_data_requests(

selector = {}

if _is_router_scope(request, user_router):
if user is not None:
selector["user"] = user

data_requests, links = await paginated_query(
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ dependencies = [
"pymongo~=4.14",
"geojson-pydantic~=2.0",
"stac-pydantic~=3.4",
"pydantic[email]~=2.11"
"pydantic[email]~=2.11",
"pydantic-settings~=2.14",
"httpx~=0.28"
]

[project.optional-dependencies]
dev = ["ruff~=0.13", "pre-commit~=4.3", "fastapi[standard]"]
prod = ["uvicorn~=0.34"]
test = ["pytest~=8.4", "faker~=37.8", "pystac[validation]~=1.14", "httpx~=0.28"]
test = ["pytest~=8.4", "faker~=37.8", "pystac[validation]~=1.14", "respx~=0.23"]

[tool.ruff]
line-length = 120
Expand Down
18 changes: 18 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import os

# set this to false by default so that tests don't require magpie authentication
# unless explicitly set during the tests
os.environ["MARBLE_API_MAGPIE_AUTH_ENABLED"] = "false"

import pytest
from faker_providers import DataRequestProvider, GeoJsonProvider

from marble_api._config import config


@pytest.fixture(scope="session")
def anyio_backend():
Expand All @@ -10,3 +18,13 @@ def anyio_backend():
@pytest.fixture(scope="session")
def faker_providers():
return {"DataRequestProvider": DataRequestProvider, "GeoJsonProvider": GeoJsonProvider}


@pytest.fixture
def test_config():
prev = config.model_dump()
try:
yield config
finally:
for k, v in prev.items():
setattr(config, k, v)
13 changes: 13 additions & 0 deletions test/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,16 @@ async def refresh_database(request):
async def async_client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
yield client


@pytest.fixture
async def enable_magpie_auth(test_config):
test_config.magpie_auth_enabled = True
test_config.magpie_admin_group = "admin_test"
test_config.magpie_url = "http://localhost/magpie"
yield


@pytest.fixture
async def auth_mock(enable_magpie_auth, respx_mock, test_config):
yield respx_mock.get(f"{test_config.magpie_url}/session")
Loading
Loading