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