diff --git a/gitea_saas/auth.py b/gitea_saas/auth.py new file mode 100644 index 0000000..913041c --- /dev/null +++ b/gitea_saas/auth.py @@ -0,0 +1,79 @@ +"""Single X-API-Key auth path for the whole service (issue #24). + +Every API-key front-end (``server.py``, ``delivery.py``, ``intake_factory.py``, +``dashboard.py``) delegates to this module. The header name, the constant-time +comparison and the 401 body are defined exactly once here; a front-end never +performs its own check or hardcodes the header literal. + +``stripe_client.py`` verifies webhook *signatures* (a different secret on a +different verification path) and is explicitly out of scope for this issue — +it keeps its own constant-time signature check. + +Public surface: + API_KEY_HEADER the only ``X-API-Key`` header literal in the package + PUBLIC_PATHS deliberately unauthenticated liveness/health routes + key_is_valid() constant-time, fail-closed check — the one API-key + comparison in the whole service + UNAUTHORIZED_BODY canonical 401 payload, byte-identical on every wire + unauthorized_response() Flask Response object carrying that body +""" + +from __future__ import annotations + +import hmac +from typing import Optional + +# The one place the HTTP header literal lives. A `git grep` for the quoted +# header name must match exactly this definition (acceptance criterion 2). +API_KEY_HEADER = "X-API-Key" + +# Routes that need no API key. Every front-end guard reads this set, so a +# route's exposure is declared here, not inside one PR's before_request +# (composition finding, 2026-08-26): /health (server.py), /healthz +# (dashboard.py) and /api/factory/health (intake_factory.py) are all +# liveness routes and must stay reachable without a key. +PUBLIC_PATHS = frozenset({ + "/health", + "/healthz", + "/api/factory/health", +}) + +# Canonical 401 payload. All front-ends return exactly this (Flask +# `jsonify(UNAUTHORIZED_BODY)` for HTTP surfaces, the dict itself for the +# delivery `(result, status)` seam), so a client parses every front-end's +# auth failure identically (acceptance criterion 4). +UNAUTHORIZED_BODY = { + "error": { + "code": "UNAUTHORIZED", + "message": "missing or invalid API key", + }, +} + + +def key_is_valid(provided_key: Optional[str], + configured_key: Optional[str]) -> bool: + """Fail-closed, constant-time API-key check (single implementation). + + A request is authorized only when a key is actually configured AND the + provided header value matches it exactly. An unset/empty or non-str + configured key refuses every request; non-str/empty provided values are + rejected before the comparison runs (the constant-time compare raises + ``TypeError`` on non-str input). + + This is the ONE API-key comparison in the service (acceptance criterion + 1) — no front-end does its own. + """ + if not isinstance(configured_key, str) or not configured_key: + return False + if not isinstance(provided_key, str) or not provided_key: + return False + return hmac.compare_digest(provided_key, configured_key) + + +def unauthorized_response(): + """Flask Response with the canonical 401 body (status 401).""" + from flask import jsonify + + resp = jsonify(UNAUTHORIZED_BODY) + resp.status_code = 401 + return resp diff --git a/gitea_saas/config.py b/gitea_saas/config.py index a0e513c..d4950c0 100644 --- a/gitea_saas/config.py +++ b/gitea_saas/config.py @@ -5,8 +5,9 @@ coordinated between issues #2 and #6 — see botmanager #21 for the ownership pattern that motivated it): * the HTTP server's frozen :class:`ServerConfig` (issue #2) — the billing - API's API-key discipline (``SAAS_API_KEY`` / ``X-API-Key`` header), the - port, and the DB path; and + API's API-key discipline (``SAAS_API_KEY`` env; the ``X-API-Key`` header + constant itself is owned by ``gitea_saas.auth``, issue #24), the port, and + the DB path; and * the review-gate/policy tuning (issue #6): tier rates/policies (``SAAS_TIERS_JSON``) plus the reviewer knobs (``REVIEW_*``), returned as a plain dict by :func:`load_review_config`. @@ -32,8 +33,11 @@ DEFAULT_PORT = 8080 # (in-memory) SQLite database. Real deployments must set SAAS_DB_PATH. DEFAULT_DB_PATH: Optional[Path] = None -# Header name carrying the API key on every authenticated request. -API_KEY_HEADER = "X-API-Key" +# Header name carrying the API key on every authenticated request. Owned by +# ``gitea_saas.auth`` (issue #24 consolidation); re-exported here so existing +# ``from .config import API_KEY_HEADER`` call sites keep working without a +# second copy of the literal. +from .auth import API_KEY_HEADER # Tier defaults (issue #6): GOLD auto-merges on a green APPROVE at rate 100 # minor units per delivery; STANDARD is reviewed but operator-merged at rate diff --git a/gitea_saas/dashboard.py b/gitea_saas/dashboard.py index 349e74b..7d67128 100644 --- a/gitea_saas/dashboard.py +++ b/gitea_saas/dashboard.py @@ -30,7 +30,6 @@ serves the exact same dict. from __future__ import annotations -import hmac import html import os import sqlite3 @@ -38,13 +37,9 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List, Optional -EMPTY = "\u2014" # em dash empty-state marker +from .auth import API_KEY_HEADER, key_is_valid, unauthorized_response -# Single source for the API-key header name on this branch. The shared -# `config.API_KEY_HEADER` constant lands with issue #2/#6 (neither merged -# yet); when #24 consolidates the service's auth paths, this line is -# replaced by `from .config import API_KEY_HEADER` — no other change. -API_KEY_HEADER = "X-API-Key" +EMPTY = "\u2014" # em dash empty-state marker DEFAULT_DEBIT_FLOOR_MINOR = 500 @@ -412,7 +407,7 @@ def render_html(summary: Dict[str, Any]) -> str:
| Repo | PR | Verdict | Head | Reviewed at |
|---|
| Client | Kind | Amount | Ref | Time |
|---|
Listen-only page: bind the server to 127.0.0.1. Live numbers, no caching.
+Operator page — requires the same API key as the JSON summary. Live numbers, no caching.