First commit

This commit is contained in:
florian committed 2026-10-29 15:28:51 +01:00
1 parent a77e0fb890
commit 6e7c2a5de6
7 files changed
+367

No files matched your search

+29
View File
@@ -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/*
+29
View File
@@ -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/*
+57
View File
@@ -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",
]
+3
View File
@@ -0,0 +1,3 @@
from service_auth.core import KeySource, ServiceAuth, UserId, require_user_id
__all__ = ["KeySource", "ServiceAuth", "UserId", "require_user_id"]
+114
View File
@@ -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)]
+1
View File
@@ -0,0 +1 @@
+134
View File
@@ -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"}