Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding route dependencies module. #251

Merged
merged 36 commits into from
Jun 22, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
5ee3c2d
Adding route dependencies module.
rhysrevans3 May 10, 2024
10d621e
Fixing pre-commit errors.
rhysrevans3 May 10, 2024
bb04359
Merge branch 'main' into route_dependencies
jonhealy1 May 10, 2024
c2c12bd
Adding to opensearch.
rhysrevans3 May 14, 2024
7b8ef5a
Adding testing.
rhysrevans3 May 14, 2024
aedf077
Merge branch 'main' into route_dependencies
jonhealy1 May 15, 2024
6a36f6b
Allow route dependencies to be passed as a variable for testing.
rhysrevans3 May 15, 2024
19b97f6
Merge branch 'route_dependencies' of github.com:rhysrevans3/stac-fast…
rhysrevans3 May 15, 2024
211d04d
Removing context extension.
rhysrevans3 May 15, 2024
8c14390
Allowing fuctions or class dependencies.
rhysrevans3 May 15, 2024
4b90d15
Updating test conf.
rhysrevans3 May 15, 2024
6891357
Fix for import error.
rhysrevans3 May 15, 2024
d718601
Add test directory to sys path for import.
rhysrevans3 May 15, 2024
a1d3929
Fix for sys path import.
rhysrevans3 May 15, 2024
65d367c
Add test dir to sys path.
rhysrevans3 May 15, 2024
53f17f8
Switching tests to use collections endpoint for rd tests.
rhysrevans3 May 15, 2024
51de3bf
Switch from post to get on test_authenticated.
rhysrevans3 May 15, 2024
ffd3912
Removing unneeded length check.
rhysrevans3 May 16, 2024
bde4b3c
Adding docker compose file and readme help for route dependencies.
rhysrevans3 May 24, 2024
bd29dcd
Merge branch 'main' of github.com:stac-utils/stac-fastapi-elasticsear…
rhysrevans3 Jun 3, 2024
cc1a30e
Adding to changelog.
rhysrevans3 Jun 3, 2024
caa45dd
Merge branch 'main' into route_dependencies
jonhealy1 Jun 11, 2024
fb931bc
Moving basic auth to route dependencies.
rhysrevans3 Jun 12, 2024
c08d25c
Merge branch 'route_dependencies' of github.com:rhysrevans3/stac-fast…
rhysrevans3 Jun 12, 2024
b689275
Removing basic_auth copy.
rhysrevans3 Jun 13, 2024
370b401
Adding route that aren't lists.
rhysrevans3 Jun 13, 2024
6a7308e
Adding schema validation to route dependencies.
rhysrevans3 Jun 17, 2024
a88f04c
pre-commit.
rhysrevans3 Jun 17, 2024
b5231f4
Merge branch 'main' of github.com:stac-utils/stac-fastapi-elasticsear…
rhysrevans3 Jun 17, 2024
deba511
Adding jsonschema install.
rhysrevans3 Jun 17, 2024
e9bba36
Fixing schema.
rhysrevans3 Jun 17, 2024
87a2294
Add docker file example for route_dependencies.json.
rhysrevans3 Jun 17, 2024
9617d39
Merge branch 'main' of github.com:stac-utils/stac-fastapi-elasticsear…
rhysrevans3 Jun 18, 2024
01b0af5
Adding Oauth2 example.
rhysrevans3 Jun 18, 2024
f9314d9
Updating changelog and opensearch auth in compose files.
rhysrevans3 Jun 21, 2024
4ab9d48
Update CHANGELOG.md
jonhealy1 Jun 22, 2024
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

### Changed
rhysrevans3 marked this conversation as resolved.
Show resolved Hide resolved

- Added route dependency configuration [#251](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/251)

- Updated stac-fastapi libraries to v3.0.0a1 [#265](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/265)

### Fixed
Expand Down
141 changes: 86 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,75 +279,106 @@ The modified Items with lowercase identifiers will now be visible to users acces

#### Environment Variable Configuration

Basic authentication is an optional feature. You can enable it by setting the environment variable `BASIC_AUTH` as a JSON string.
Basic authentication is an optional feature that can be enabled through [Route Dependencies](#route-dependencies).

Example:
```
BASIC_AUTH={"users":[{"username":"user","password":"pass","permissions":"*"}]}
```

### User Permissions Configuration
### Example Configuration

In order to set endpoints with specific access permissions, you can configure the `users` key with a list of user objects. Each user object should contain the username, password, and their respective permissions.

Example: This example illustrates the configuration for two users: an **admin** user with full permissions (*) and a **reader** user with limited permissions to specific read-only endpoints.
This example illustrates the configuration for two users: an **admin** user with full permissions (*) and a **reader** user with limited permissions to specific read-only endpoints.
```json
{
"users": [
{
"username": "admin",
"password": "admin",
"permissions": "*"
},
{
"username": "reader",
"password": "reader",
"permissions": [
{"path": "/", "method": ["GET"]},
{"path": "/conformance", "method": ["GET"]},
{"path": "/collections/{collection_id}/items/{item_id}", "method": ["GET"]},
{"path": "/search", "method": ["GET", "POST"]},
{"path": "/collections", "method": ["GET"]},
{"path": "/collections/{collection_id}", "method": ["GET"]},
{"path": "/collections/{collection_id}/items", "method": ["GET"]},
{"path": "/queryables", "method": ["GET"]},
{"path": "/queryables/collections/{collection_id}/queryables", "method": ["GET"]},
{"path": "/_mgmt/ping", "method": ["GET"]}
]
[
{
"routes": [
{
"method": "*",
"path": "*"
}
],
"dependencies": [
{
"method": "stac_fastapi.core.basic_auth.BasicAuth",
"kwargs": {
"credentials":[
{
"username": "admin",
"password": "admin"
}
]
}
}
]
},
{
"routes": [
{"path": "/", "method": ["GET"]},
{"path": "/conformance", "method": ["GET"]},
{"path": "/collections/{collection_id}/items/{item_id}", "method": ["GET"]},
{"path": "/search", "method": ["GET", "POST"]},
{"path": "/collections", "method": ["GET"]},
{"path": "/collections/{collection_id}", "method": ["GET"]},
{"path": "/collections/{collection_id}/items", "method": ["GET"]},
{"path": "/queryables", "method": ["GET"]},
{"path": "/queryables/collections/{collection_id}/queryables", "method": ["GET"]},
{"path": "/_mgmt/ping", "method": ["GET"]}
],
"dependencies": [
{
"method": "stac_fastapi.core.basic_auth.BasicAuth",
"kwargs": {
"credentials":[
{
"username": "reader",
"password": "reader"
}
]
}
}
]
}
}
]
```

## Route Dependencies

### Public Endpoints Configuration
### Configuration

In order to set endpoints with public access, you can configure the public_endpoints key with a list of endpoint objects. Each endpoint object should specify the path and method of the endpoint.
Route dependencies for endpoints can enable through the `STAC_FASTAPI_ROUTE_DEPENDENCIES`
environment variable as a path to a JSON file or a JSON string.

Example: This example demonstrates the configuration for public endpoints, allowing access without authentication to read-only endpoints.
```json
{
"public_endpoints": [
{"path": "/", "method": "GET"},
{"path": "/conformance", "method": "GET"},
{"path": "/collections/{collection_id}/items/{item_id}", "method": "GET"},
{"path": "/search", "method": "GET"},
{"path": "/search", "method": "POST"},
{"path": "/collections", "method": "GET"},
{"path": "/collections/{collection_id}", "method": "GET"},
{"path": "/collections/{collection_id}/items", "method": "GET"},
{"path": "/queryables", "method": "GET"},
{"path": "/queryables/collections/{collection_id}/queryables", "method": "GET"},
{"path": "/_mgmt/ping", "method": "GET"}
#### Route Dependency

A Route Dependency must include `routes`, a list of at least one [Route](#routes), and `dependencies` a
list of at least one [Dependency](#dependencies).

#### Routes

A Route must include a `path`, the relative path to the endpoint, and a `method`, the request method of the path.

#### Dependencies

A Dependency must include the `method`, a dot seperated path to the Class or Function, and
can include any `args` or `kwargs` for the method.

#### Example
```
STAC_FASTAPI_ROUTE_DEPENDENCIES=[
{
"routes": [
{
"method": "GET",
"path": "/collections"
}
],
"users": [
{
"username": "admin",
"password": "admin",
"permissions": "*"
"dependencies": [
{
"method": "fastapi.security.OAuth2PasswordBearer",
jonhealy1 marked this conversation as resolved.
Show resolved Hide resolved
"kwargs": {
"tokenUrl": "token"
}
}
]
}
}
]
```

### Docker Compose Configurations
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.basic_auth_protected.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ services:
- ES_USE_SSL=false
- ES_VERIFY_CERTS=false
- BACKEND=elasticsearch
- BASIC_AUTH={"users":[{"username":"admin","password":"admin","permissions":"*"},{"username":"reader","password":"reader","permissions":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET"]},{"path":"/search","method":["GET","POST"]},{"path":"/collections","method":["GET"]},{"path":"/collections/{collection_id}","method":["GET"]},{"path":"/collections/{collection_id}/items","method":["GET"]},{"path":"/queryables","method":["GET"]},{"path":"/queryables/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}]}]}
- STAC_FASTAPI_ROUTE_DEPENDENCIES=[{"routes":[{"method":"*","path":"*"}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"admin","password":"admin"}]}}]},{"routes":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET"]},{"path":"/search","method":["GET","POST"]},{"path":"/collections","method":["GET"]},{"path":"/collections/{collection_id}","method":["GET"]},{"path":"/collections/{collection_id}/items","method":["GET"]},{"path":"/queryables","method":["GET"]},{"path":"/queryables/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"reader","password":"reader"}]}}]}]
rhysrevans3 marked this conversation as resolved.
Show resolved Hide resolved
ports:
- "8080:8080"
volumes:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ services:
- ES_USE_SSL=false
- ES_VERIFY_CERTS=false
- BACKEND=elasticsearch
- BASIC_AUTH={"public_endpoints":[{"path":"/","method":"GET"},{"path":"/conformance","method":"GET"},{"path":"/collections/{collection_id}/items/{item_id}","method":"GET"},{"path":"/search","method":"GET"},{"path":"/search","method":"POST"},{"path":"/collections","method":"GET"},{"path":"/collections/{collection_id}","method":"GET"},{"path":"/collections/{collection_id}/items","method":"GET"},{"path":"/queryables","method":"GET"},{"path":"/queryables/collections/{collection_id}/queryables","method":"GET"},{"path":"/_mgmt/ping","method":"GET"}],"users":[{"username":"admin","password":"admin","permissions":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET","POST","PUT","DELETE"]},{"path":"/search","method":["GET","POST"]},{"path":"/collections","method":["GET","PUT","POST"]},{"path":"/collections/{collection_id}","method":["GET","DELETE"]},{"path":"/collections/{collection_id}/items","method":["GET","POST"]},{"path":"/queryables","method":["GET"]},{"path":"/queryables/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}]}]}
- STAC_FASTAPI_ROUTE_DEPENDENCIES=[{"routes":[{"method":"GET","path":"/collections"}],"dependencies":[{"method":"conftest.must_be_bob"}]}]
ports:
- "8080:8080"
volumes:
Expand Down Expand Up @@ -55,7 +55,7 @@ services:
- ES_USE_SSL=false
- ES_VERIFY_CERTS=false
- BACKEND=opensearch
- BASIC_AUTH={"public_endpoints":[{"path":"/","method":"GET"},{"path":"/conformance","method":"GET"},{"path":"/collections/{collection_id}/items/{item_id}","method":"GET"},{"path":"/search","method":"GET"},{"path":"/search","method":"POST"},{"path":"/collections","method":"GET"},{"path":"/collections/{collection_id}","method":"GET"},{"path":"/collections/{collection_id}/items","method":"GET"},{"path":"/queryables","method":"GET"},{"path":"/queryables/collections/{collection_id}/queryables","method":"GET"},{"path":"/_mgmt/ping","method":"GET"}],"users":[{"username":"admin","password":"admin","permissions":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET","POST","PUT","DELETE"]},{"path":"/search","method":["GET","POST"]},{"path":"/collections","method":["GET","PUT","POST"]},{"path":"/collections/{collection_id}","method":["GET","DELETE"]},{"path":"/collections/{collection_id}/items","method":["GET","POST"]},{"path":"/queryables","method":["GET"]},{"path":"/queryables/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}]}]}
- BASIC_AUTH={"users":[{"username":"admin","password":"admin","permissions":"*"},{"username":"reader","password":"reader","permissions":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET"]},{"path":"/search","method":["GET","POST"]},{"path":"/collections","method":["GET"]},{"path":"/collections/{collection_id}","method":["GET"]},{"path":"/collections/{collection_id}/items","method":["GET"]},{"path":"/queryables","method":["GET"]},{"path":"/queryables/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}]}]}
ports:
- "8082:8082"
volumes:
Expand Down
21 changes: 21 additions & 0 deletions examples/route_dependencies/route_dependencies.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[
{
"routes": [
{
"method": "GET",
"path": "/collections"
}
],
"dependencies": [
{
"method": "fastapi.security.OAuth2PasswordBearer",
"kwargs": {
"tokenUrl": "token"
}
},
{
"method": "stac_fastapi.conftest.must_be_bob"
}
]
}
]
148 changes: 47 additions & 101 deletions stac_fastapi/core/stac_fastapi/core/basic_auth.py
Original file line number Diff line number Diff line change
@@ -1,115 +1,61 @@
"""Basic Authentication Module."""

import json
import logging
import os
import secrets
from typing import Any, Dict

from fastapi import Depends, HTTPException, Request, status
from fastapi.routing import APIRoute
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from typing_extensions import Annotated

from stac_fastapi.api.app import StacApi

_LOGGER = logging.getLogger("uvicorn.default")
_SECURITY = HTTPBasic()
_BASIC_AUTH: Dict[str, Any] = {}


def has_access(
request: Request, credentials: Annotated[HTTPBasicCredentials, Depends(_SECURITY)]
) -> str:
"""Check if the provided credentials match the expected \
username and password stored in environment variables for basic authentication.

Args:
request (Request): The FastAPI request object.
credentials (HTTPBasicCredentials): The HTTP basic authentication credentials.

Returns:
str: The username if authentication is successful.

Raises:
HTTPException: If authentication fails due to incorrect username or password.
"""
global _BASIC_AUTH

users = _BASIC_AUTH.get("users")
user: Dict[str, Any] = next(
(u for u in users if u.get("username") == credentials.username), {}
)

if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)

# Compare the provided username and password with the correct ones using compare_digest
if not secrets.compare_digest(
credentials.username.encode("utf-8"), user.get("username").encode("utf-8")
) or not secrets.compare_digest(
credentials.password.encode("utf-8"), user.get("password").encode("utf-8")
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)

permissions = user.get("permissions", [])
path = request.scope.get("route").path
method = request.method

if permissions == "*":
return credentials.username
for permission in permissions:
if permission["path"] == path and method in permission.get("method", []):
return credentials.username

raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions for [{method} {path}]",
)


def apply_basic_auth(api: StacApi) -> None:
class BasicAuth:
"""Apply basic authentication to the provided FastAPI application \
based on environment variables for username, password, and endpoints.

Args:
api (StacApi): The FastAPI application.

Raises:
HTTPException: If there are issues with the configuration or format
of the environment variables.
"""
global _BASIC_AUTH
based on environment variables for username, password, and endpoints."""

def __init__(self, credentials: list) -> None:
"""Generate basic_auth property."""
self.basic_auth = {}
for credential in credentials:
self.basic_auth[credential["username"]] = credential

async def __call__(
self,
credentials: Annotated[HTTPBasicCredentials, Depends(_SECURITY)],
) -> str:
"""Check if the provided credentials match the expected \
username and password stored in basic_auth.

Args:
credentials (HTTPBasicCredentials): The HTTP basic authentication credentials.

Returns:
str: The username if authentication is successful.

Raises:
HTTPException: If authentication fails due to incorrect username or password.
"""
user = self.basic_auth.get(credentials.username)

if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)

# Compare the provided username and password with the correct ones using compare_digest
if not secrets.compare_digest(
credentials.username.encode("utf-8"), user.get("username").encode("utf-8")
) or not secrets.compare_digest(
credentials.password.encode("utf-8"), user.get("password").encode("utf-8")
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)

basic_auth_json_str = os.environ.get("BASIC_AUTH")
if not basic_auth_json_str:
_LOGGER.info("Basic authentication disabled.")
return

try:
_BASIC_AUTH = json.loads(basic_auth_json_str)
except json.JSONDecodeError as exception:
_LOGGER.error(f"Invalid JSON format for BASIC_AUTH. {exception=}")
raise
public_endpoints = _BASIC_AUTH.get("public_endpoints", [])
users = _BASIC_AUTH.get("users")
if not users:
raise Exception("Invalid JSON format for BASIC_AUTH. Key 'users' undefined.")

app = api.app
for route in app.routes:
if isinstance(route, APIRoute):
for method in route.methods:
endpoint = {"path": route.path, "method": method}
if endpoint not in public_endpoints:
api.add_route_dependencies([endpoint], [Depends(has_access)])

_LOGGER.info("Basic authentication enabled.")
return credentials.username
Loading