From 6e7c2a5de6ba9246030a6300553482273043803b Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 29 Oct 2026 15:28:51 +0100 Subject: [PATCH] First commit --- .gitea/workflows/build-local.yaml | 29 +++++++ .gitea/workflows/build.yaml | 29 +++++++ pyproject.toml | 57 +++++++++++++ src/service_auth/__init__.py | 3 + src/service_auth/core.py | 114 +++++++++++++++++++++++++ src/service_auth/py.typed | 1 + tests/test_service_auth.py | 134 ++++++++++++++++++++++++++++++ 7 files changed, 367 insertions(+) create mode 100644 .gitea/workflows/build-local.yaml create mode 100644 .gitea/workflows/build.yaml create mode 100644 pyproject.toml create mode 100644 src/service_auth/__init__.py create mode 100644 src/service_auth/core.py create mode 100644 src/service_auth/py.typed create mode 100644 tests/test_service_auth.py diff --git a/.gitea/workflows/build-local.yaml b/.gitea/workflows/build-local.yaml new file mode 100644 index 0000000..ce10c70 --- /dev/null +++ b/.gitea/workflows/build-local.yaml @@ -0,0 +1,29 @@ +name: Build and Publish Package via local runner + +on: + workflow_dispatch: + +jobs: + build: + runs-on: local + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Prepare environment + run: | + virtualenv -p python3 .venv + source .venv/bin/activate + pip install build twine + pip install -e ".[test]" + - name: Run Tests + run: | + .venv/bin/python -m pytest + - name: Build package + run: .venv/bin/python -m build + - name: Publish package + run: | + .venv/bin/python -m twine upload \ + --repository-url https://git.gansejunge.com/api/packages/notifier/pypi \ + -u ${{ secrets.PACKAGE_UPLOAD_USERNAME }} \ + -p ${{ secrets.PACKAGE_UPLOAD_TOKEN }} \ + dist/* diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..1fc46dc --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,29 @@ +name: Build and Publish Package + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Prepare environment + run: | + virtualenv -p python3 .venv + source .venv/bin/activate + pip install build twine + pip install -e ".[test]" + - name: Run Tests + run: | + .venv/bin/python -m pytest + - name: Build package + run: .venv/bin/python -m build + - name: Publish package + run: | + .venv/bin/python -m twine upload \ + --repository-url https://git.gansejunge.com/api/packages/notifier/pypi \ + -u ${{ secrets.PACKAGE_UPLOAD_USERNAME }} \ + -p ${{ secrets.PACKAGE_UPLOAD_TOKEN }} \ + dist/* diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1e9012f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "lib-service-auth" +version = "0.1.0" +description = "Service-to-service bearer-key authentication and X-User-Id handling for FastAPI" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.110", + "lib-secret-manager", +] + +[project.optional-dependencies] +test = [ + "pytest>=8", + "httpx2", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +service_auth = ["py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 120 +target-version = "py312" +[tool.ruff.lint] +select = [ + "E", "F", # pycodestyle errors, pyflakes (unused imports/variables, undefined names) + "I", # import sorting (replaces isort) + "B", # bugbear: likely bugs + "ASYNC", # async mistakes + "RUF006", # un-referenced asyncio.create_task (tasks can be garbage-collected) +] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["db_module", "metrics_server", "rabbitmq_handler", "secret_manager", + "simple_logger_handler", "send_notifications", "service_auth"] + +[tool.ruff.format] +indent-style = "tab" + +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.Query", + "fastapi.Header", + "fastapi.Body", + "fastapi.Security", +] \ No newline at end of file diff --git a/src/service_auth/__init__.py b/src/service_auth/__init__.py new file mode 100644 index 0000000..b97b95c --- /dev/null +++ b/src/service_auth/__init__.py @@ -0,0 +1,3 @@ +from service_auth.core import KeySource, ServiceAuth, UserId, require_user_id + +__all__ = ["KeySource", "ServiceAuth", "UserId", "require_user_id"] diff --git a/src/service_auth/core.py b/src/service_auth/core.py new file mode 100644 index 0000000..60fee51 --- /dev/null +++ b/src/service_auth/core.py @@ -0,0 +1,114 @@ +"""Service-to-service authentication for FastAPI. + +Each calling service has its own bearer key. An app loads only the keys it +should accept (stored on ``app.state``). +""" + +import secrets +from collections.abc import Mapping +from typing import Annotated, Any, Generic, NamedTuple, TypeVar + +from fastapi import Depends, FastAPI, Header, HTTPException, Request, Security +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from secret_manager.credentials_loader import get_secret + +C = TypeVar("C", bound=str) + +_bearer = HTTPBearer(auto_error=False) + + +class KeySource(NamedTuple): + """Where a caller's key comes from: env var (dev) or file (Kubernetes secret).""" + env_var: str + path: str + + +class ServiceAuth(Generic[C]): + """Maps bearer keys to named callers. + + ``C`` is the caller type. A ``StrEnum`` is recommended so typos are caught + by the type checker, but plain strings work too. + """ + + def __init__( + self, + sources: Mapping[C, KeySource], + *, + state_attr: str = "service_keys", + min_key_length: int = 32, + ) -> None: + self._sources = dict(sources) + self._state_attr = state_attr + self._min_key_length = min_key_length + + def load_keys(self, app: FastAPI, *callers: C) -> None: + """Load the keys for exactly these callers onto ``app.state``. + + Raises if a key is missing or too short, or if two callers share a key + (which would make caller identity ambiguous). + """ + keys: dict[C, bytes] = {} + for caller in callers: + env_var, path = self._sources[caller] + key = get_secret(env_var, path).encode() + if len(key) < self._min_key_length: + raise ValueError( + f"Key for {caller!s} is too short (min {self._min_key_length} chars)" + ) + keys[caller] = key + if len(set(keys.values())) != len(keys): + raise ValueError("Two callers share the same key") + setattr(app.state, self._state_attr, keys) + + def key_paths(self, *callers: C) -> set[str]: + """File paths for these callers, for ``cleanup_secret_files``.""" + return {self._sources[c].path for c in callers} + + def authenticate( + self, + request: Request, + creds: Annotated[HTTPAuthorizationCredentials | None, Security(_bearer)], + ) -> C: + """Dependency: validate the bearer key and return the matching caller. + + Use ``Depends(auth.authenticate)`` at app level for default-deny. + """ + unauthorized = HTTPException( + 401, "Unauthorized", headers={"WWW-Authenticate": "Bearer"} + ) + keys: dict[C, bytes] | None = getattr(request.app.state, self._state_attr, None) + if keys is None: + # Fail closed: a forgotten load_keys() must never mean "open". + raise RuntimeError("Service keys not loaded; call load_keys() at startup") + if creds is None: + raise unauthorized + presented = creds.credentials.encode() + match: C | None = None + for caller, key in keys.items(): + if secrets.compare_digest(presented, key): + match = caller + if match is None: + raise unauthorized + return match + + def require(self, *allowed: C): + """Dependency factory: only the given callers may use the route.""" + allowed_set = frozenset(allowed) + + def dependency(caller: Annotated[Any, Depends(self.authenticate)]) -> C: + if caller not in allowed_set: + raise HTTPException(403, "Forbidden") + return caller + + return dependency + + +def require_user_id( + x_user_id: Annotated[int, Header(alias="X-User-Id", gt=0)], +) -> int: + """Dependency: the user ID asserted by an already-authenticated caller.""" + return x_user_id + + +UserId = Annotated[int, Depends(require_user_id)] diff --git a/src/service_auth/py.typed b/src/service_auth/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/service_auth/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_service_auth.py b/tests/test_service_auth.py new file mode 100644 index 0000000..323aa07 --- /dev/null +++ b/tests/test_service_auth.py @@ -0,0 +1,134 @@ +from enum import StrEnum + +import pytest +from fastapi import APIRouter, Depends, FastAPI +from fastapi.testclient import TestClient + +from service_auth import KeySource, ServiceAuth, UserId + +GATEWAY_KEY = "g" * 40 +SCHEDULER_KEY = "s" * 40 +PROM_KEY = "p" * 40 + + +class Caller(StrEnum): + GATEWAY = "gateway" + SCHEDULER = "scheduler" + PROMETHEUS = "prometheus" + + +SOURCES = { + Caller.GATEWAY: KeySource("GATEWAY_KEY", "/nonexistent/gateway_key"), + Caller.SCHEDULER: KeySource("SCHEDULER_KEY", "/nonexistent/scheduler_key"), + Caller.PROMETHEUS: KeySource("PROMETHEUS_KEY", "/nonexistent/prometheus_key"), +} + + +@pytest.fixture(autouse=True) +def keys(monkeypatch): + monkeypatch.setenv("GATEWAY_KEY", GATEWAY_KEY) + monkeypatch.setenv("SCHEDULER_KEY", SCHEDULER_KEY) + monkeypatch.setenv("PROMETHEUS_KEY", PROM_KEY) + + +def make_app(auth: ServiceAuth, load: bool = True) -> FastAPI: + app = FastAPI(dependencies=[Depends(auth.authenticate)]) + user_router = APIRouter(dependencies=[Depends(auth.require(Caller.GATEWAY))]) + sched_router = APIRouter(dependencies=[Depends(auth.require(Caller.SCHEDULER))]) + + @user_router.get("/me") + def me(user_id: UserId): + return {"user_id": user_id} + + @sched_router.post("/update") + def update(): + return {"ok": True} + + app.include_router(user_router) + app.include_router(sched_router) + if load: + auth.load_keys(app, Caller.GATEWAY, Caller.SCHEDULER) + return app + + +@pytest.fixture +def auth(): + return ServiceAuth(SOURCES) + + +@pytest.fixture +def client(auth): + return TestClient(make_app(auth)) + + +def bearer(key): + return {"Authorization": f"Bearer {key}"} + + +def test_no_key_is_401(client): + assert client.post("/update").status_code == 401 + + +def test_wrong_key_is_401(client): + assert client.post("/update", headers=bearer("x" * 40)).status_code == 401 + + +def test_right_caller_succeeds(client): + assert client.post("/update", headers=bearer(SCHEDULER_KEY)).status_code == 200 + + +def test_valid_key_wrong_caller_is_403(client): + r = client.get("/me", headers={**bearer(SCHEDULER_KEY), "X-User-Id": "1"}) + assert r.status_code == 403 + + +def test_user_id_is_read_from_header(client): + r = client.get("/me", headers={**bearer(GATEWAY_KEY), "X-User-Id": "7"}) + assert r.status_code == 200 + assert r.json() == {"user_id": 7} + + +@pytest.mark.parametrize("value", ["0", "-1", "abc"]) +def test_bad_user_id_is_422(client, value): + r = client.get("/me", headers={**bearer(GATEWAY_KEY), "X-User-Id": value}) + assert r.status_code == 422 + + +def test_missing_user_id_is_422(client): + assert client.get("/me", headers=bearer(GATEWAY_KEY)).status_code == 422 + + +def test_key_not_loaded_on_this_app_is_rejected(auth): + # The metrics-style app only knows the Prometheus key + app = FastAPI(dependencies=[Depends(auth.require(Caller.PROMETHEUS))]) + + @app.get("/metrics") + def metrics(): + return "ok" + + auth.load_keys(app, Caller.PROMETHEUS) + c = TestClient(app) + assert c.get("/metrics", headers=bearer(PROM_KEY)).status_code == 200 + assert c.get("/metrics", headers=bearer(GATEWAY_KEY)).status_code == 401 + + +def test_unloaded_keys_fail_closed(auth): + app = make_app(auth, load=False) + c = TestClient(app, raise_server_exceptions=False) + assert c.post("/update", headers=bearer(SCHEDULER_KEY)).status_code == 500 + + +def test_short_key_rejected_at_load(auth, monkeypatch): + monkeypatch.setenv("GATEWAY_KEY", "short") + with pytest.raises(ValueError, match="too short"): + auth.load_keys(FastAPI(), Caller.GATEWAY) + + +def test_shared_key_rejected_at_load(auth, monkeypatch): + monkeypatch.setenv("SCHEDULER_KEY", GATEWAY_KEY) + with pytest.raises(ValueError, match="share"): + auth.load_keys(FastAPI(), Caller.GATEWAY, Caller.SCHEDULER) + + +def test_key_paths(auth): + assert auth.key_paths(Caller.GATEWAY) == {"/nonexistent/gateway_key"}