First commit
This commit is contained in:
1 parent
a77e0fb890
commit
6e7c2a5de6
7 files changed
+367
No files matched your search
@@ -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/*
|
||||||
@@ -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/*
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from service_auth.core import KeySource, ServiceAuth, UserId, require_user_id
|
||||||
|
|
||||||
|
__all__ = ["KeySource", "ServiceAuth", "UserId", "require_user_id"]
|
||||||
@@ -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)]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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"}
|
||||||
Reference in new issue
Block a user