Skip to content
Open
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
25 changes: 25 additions & 0 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,28 @@ jobs:
- name: '[check] Run Ruff'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"The original approach to authentication where if a single tenant was compromised, attacker could issue uuids with any tenant uuid they want, which would compromise all other tenants as well."

What uuids do you mean? Like new tenant uuis?

working-directory: ./service
run: make typecheck

service-test:
name: 'Service: Test'
runs-on: ubuntu-latest

steps:
- name: '[setup] Check out repository'
uses: actions/checkout@v7

- name: '[setup] Install uv'
uses: astral-sh/setup-uv@v7
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: '[setup] Set up Python'
working-directory: ./service
run: uv python install

- name: '[app] Install'
working-directory: ./service
run: make install

- name: '[check] Run pytest'
working-directory: ./service
run: make test
4 changes: 4 additions & 0 deletions service/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ lint:
typecheck:
uv run ty check

.PHONY: test
test:
uv run pytest

.PHONY: format
format:
uv run ruff format
Expand Down
11 changes: 8 additions & 3 deletions service/config.template.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
# Specify urls which can be used for authenticating the user. The user will send the url in a header 'X-Dsw-Api-Url'
# Specify urls and tenant ids which can be used for authenticating the user. The user will send the url in a header 'X-Dsw-Api-Url'
# Use the base url for dsw api, not the frontend.
# For example: https://researchers.dsw.elixir-europe.org/wizard-api
# Get the tenant UUID from the JWT token
# For debugging purposes, you can set url or tenant_uuid to * to allow all urls or tenant uuids.
auth:
allowed_project_urls:
- "https://your-dsw-instance.example.com/wizard-api"
allowed_apis:
- url: "https://your-dsw-instance.example.com/wizard-api"
tenant_uuid: "123e4567-e89b-12d3-a456-426614174000"
- url: "*"
tenant_uuid: "*"

logging:
level: "INFO"
Expand Down
20 changes: 20 additions & 0 deletions service/config.test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
auth:
allowed_apis:
- url: "https://your-dsw-instance.example.com/wizard-api"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Does this mean that every tenant must now be explicitly listed in the configuration?
I’m wondering whether this might be too tightly coupled to the config. Would each newly created tenant need to be added manually before it can use the API?

tenant_uuid: "123e4567-e89b-12d3-a456-426614174000"

logging:
level: "INFO"

database:
host: "localhost"
port: "5432"
name: "ai_document_plugin"
user: "ai_document_plugin"
password: "ai_document_plugin"
schema: "public"

files:
prompts_path: "prompts.yaml"

max_parallel_executions: 2
5 changes: 5 additions & 0 deletions service/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ dev = [
"ruff",
"ty",
"pytest",
"pytest-asyncio"
]

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

[build-system]
requires = ["uv_build>=0.11.23,<0.12.0"]
build-backend = "uv_build"
Expand Down
66 changes: 53 additions & 13 deletions service/src/ai_document_plugin_service/ai/common/config.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import logging
import os
import pathlib
from dataclasses import dataclass
from typing import Any
from uuid import UUID

import yaml

logger = logging.getLogger(__name__)

DEFAULT_CONFIG_PATH = 'config.yaml'
CONFIG_PATH_ENV_VAR = 'AI_DOCUMENT_PLUGIN_CONFIG_PATH'
WILDCARD = '*'


@dataclass(frozen=True)
Expand All @@ -29,6 +34,12 @@ class FilePaths:
prompts_path: str


@dataclass(frozen=True)
class AllowedApi:
url: str
tenant_uuid: str


@dataclass(frozen=True)
class DatabaseConfig:
host: str
Expand All @@ -41,7 +52,7 @@ class DatabaseConfig:

@dataclass(frozen=True)
class Config:
allowed_project_urls: tuple[str, ...]
allowed_apis: tuple[AllowedApi, ...]
log_level: str
database: DatabaseConfig
files: FilePaths
Expand Down Expand Up @@ -114,20 +125,49 @@ def normalize_project_url(url: str) -> str:
return url.strip().rstrip('/')


def _get_allowed_project_urls(config: dict[str, Any]) -> tuple[str, ...]:
raw_urls = _get(config, 'auth', 'allowed_project_urls')
if not isinstance(raw_urls, list) or not raw_urls:
msg = "Invalid config value: 'auth.allowed_project_urls' must be a non-empty list"
raise ValueError(msg)
def _get_allowed_apis(config: dict[str, Any]) -> tuple[AllowedApi, ...]:
raw_apis = _get(config, 'auth', 'allowed_apis')
if not isinstance(raw_apis, list) or not raw_apis:
msg = "Invalid config value: 'auth.allowed_apis' must be a non-empty list"
raise TypeError(msg)

normalized: list[str] = []
for index, entry in enumerate(raw_urls):
if not isinstance(entry, str) or not entry.strip():
msg = f"Invalid config value: 'auth.allowed_project_urls[{index}]' must be a non-empty string"
allowed_apis: list[AllowedApi] = []
for index, entry in enumerate(raw_apis):
if not isinstance(entry, dict):
msg = f"Invalid config value: 'auth.allowed_apis[{index}]' must be a mapping"
raise TypeError(msg)

raw_url = entry.get('url')
raw_tenant_uuid = entry.get('tenant_uuid')
if not isinstance(raw_url, str) or not raw_url.strip():
msg = f"Invalid config value: 'auth.allowed_apis[{index}].url' must be a non-empty string"
raise ValueError(msg)
if not isinstance(raw_tenant_uuid, str) or not raw_tenant_uuid.strip():
msg = f"Invalid config value: 'auth.allowed_apis[{index}].tenant_uuid' must be a non-empty string"
raise ValueError(msg)
normalized.append(normalize_project_url(entry))

return tuple(normalized)
url = raw_url.strip()
tenant_uuid = raw_tenant_uuid.strip()
normalized_url = url if url == WILDCARD else normalize_project_url(url)
if tenant_uuid != WILDCARD:
try:
tenant_uuid = str(UUID(tenant_uuid))
except ValueError as error:
msg = f"Invalid config value: 'auth.allowed_apis[{index}].tenant_uuid' must be a UUID or '*'"
raise ValueError(msg) from error

if WILDCARD in {normalized_url, tenant_uuid}:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It might be useful to introduce separate production and development modes in the future. But maybe it is too much for this project, just an idea.

logger.warning(
"Do not use in production! Config 'auth.allowed_apis[%d]' uses a wildcard (url=%s, tenant_uuid=%s) "
'which allows anyone to use this API.',
index,
normalized_url,
tenant_uuid,
)

allowed_apis.append(AllowedApi(url=normalized_url, tenant_uuid=tenant_uuid))

return tuple(allowed_apis)


def _resolve_existing_path(path: str, *, base_dir: pathlib.Path | None = None) -> str:
Expand Down Expand Up @@ -182,7 +222,7 @@ def load_config(config_path: str | None = None) -> Config:
raise TypeError(msg)

return Config(
allowed_project_urls=_get_allowed_project_urls(config),
allowed_apis=_get_allowed_apis(config),
log_level=_get_log_level(config),
database=DatabaseConfig(
host=_expand_env_vars(_get(config, 'database', 'host')),
Expand Down
27 changes: 18 additions & 9 deletions service/src/ai_document_plugin_service/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import fastapi
import httpx

from ai_document_plugin_service.ai.common.config import Config, normalize_project_url
from ai_document_plugin_service.ai.common.config import WILDCARD, AllowedApi, Config, normalize_project_url
from ai_document_plugin_service.api.jwt import extract_identity_from_token

DSW_API_URL_HEADER = 'X-Dsw-Api-Url'
Expand All @@ -21,8 +21,17 @@ class AuthenticatedUser:
tenant_uuid: UUID


def is_allowed_project_url(api_url: str, allowed_project_urls: tuple[str, ...]) -> bool:
return normalize_project_url(api_url) in allowed_project_urls
def is_allowed_request(api_url: str, tenant_uuid: UUID, allowed_apis: tuple[AllowedApi, ...]) -> bool:
normalized_api_url = normalize_project_url(api_url)
tenant_uuid_str = str(tenant_uuid)

for entry in allowed_apis:
url_matches = entry.url in {WILDCARD, normalized_api_url}
tenant_matches = entry.tenant_uuid in {WILDCARD, tenant_uuid_str}
if not (url_matches and tenant_matches):
continue
return True
return False


def _parse_bearer_token(authorization: str | None) -> str | None:
Expand Down Expand Up @@ -65,15 +74,15 @@ def verify_authenticated(
config: Config = request.app.state.config
normalized_api_url = normalize_project_url(dsw_api_url)

if not is_allowed_project_url(normalized_api_url, config.allowed_project_urls):
raise fastapi.HTTPException(status_code=401, detail='Unauthorized')

if not _validate_dsw_user(normalized_api_url, token):
raise fastapi.HTTPException(status_code=401, detail='Unauthorized')

try:
user_uuid, tenant_uuid = extract_identity_from_token(token)
except ValueError as error:
raise fastapi.HTTPException(status_code=400, detail=str(error)) from error

if not is_allowed_request(normalized_api_url, tenant_uuid, config.allowed_apis):
raise fastapi.HTTPException(status_code=401, detail='Unauthorized')

if not _validate_dsw_user(normalized_api_url, token):
raise fastapi.HTTPException(status_code=401, detail='Unauthorized')

return AuthenticatedUser(token=token, api_url=normalized_api_url, user_uuid=user_uuid, tenant_uuid=tenant_uuid)
Loading
Loading