From 1ad50fc4a57d0b631b194e049e8e757e968628e0 Mon Sep 17 00:00:00 2001 From: "m2 (AI Agent)" Date: Thu, 2 Jul 2026 02:34:09 +0200 Subject: [PATCH] T011: ledger X-API-Key auth (service/admin) --- ledger/src/m2_ledger/auth.py | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 ledger/src/m2_ledger/auth.py diff --git a/ledger/src/m2_ledger/auth.py b/ledger/src/m2_ledger/auth.py new file mode 100644 index 0000000..6e6580b --- /dev/null +++ b/ledger/src/m2_ledger/auth.py @@ -0,0 +1,53 @@ +"""X-API-Key auth for m2-ledger (contracts/ledger-api.md ยง4). + +Two key classes, both read from env as comma-separated lists: +- LEDGER_SERVICE_KEYS: service-class keys (CLI/Store/indexer). +- LEDGER_ADMIN_KEYS: admin-class keys (grants, snapshots). + +`require_service` accepts either class; `require_admin` accepts admin only. +Keys are loaded lazily (first request) so tests can set env vars before import +without needing to reload the module. +""" + +import hmac +import os +from functools import lru_cache + +from fastapi import Header, HTTPException + +UNAUTHORIZED = HTTPException(status_code=401, detail={"error": "unauthorized"}) + + +def _parse_keys(env_var: str) -> frozenset[str]: + raw = os.environ.get(env_var, "") + return frozenset(key.strip() for key in raw.split(",") if key.strip()) + + +@lru_cache(maxsize=1) +def _service_keys() -> frozenset[str]: + return _parse_keys("LEDGER_SERVICE_KEYS") + + +@lru_cache(maxsize=1) +def _admin_keys() -> frozenset[str]: + return _parse_keys("LEDGER_ADMIN_KEYS") + + +def _matches_any(candidate: str, keys: frozenset[str]) -> bool: + return any(hmac.compare_digest(candidate, key) for key in keys) + + +def require_service(x_api_key: str = Header(default=None)) -> str: + """Depends()-compatible: accepts a service OR admin key.""" + if x_api_key and ( + _matches_any(x_api_key, _service_keys()) or _matches_any(x_api_key, _admin_keys()) + ): + return x_api_key + raise UNAUTHORIZED + + +def require_admin(x_api_key: str = Header(default=None)) -> str: + """Depends()-compatible: admin key only.""" + if x_api_key and _matches_any(x_api_key, _admin_keys()): + return x_api_key + raise UNAUTHORIZED