From f39343ea81cbeac808d2b4f02418d5b7f4f4bc62 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 04:34:50 -0500 Subject: [PATCH] =?UTF-8?q?gitea-saas-core#41:=20client-facing=20surface?= =?UTF-8?q?=20=E2=80=94=20per-client=20keys,=20checkout,=20/api/me,=20self?= =?UTF-8?q?-serve=20work=20submission,=20Stripe=20webhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client_api.py: ClientKeyStore (SHA-256 hashed at rest, scoped, revocable), checkout with per-client rate limit, /api/me status, /api/me/issues submit, signature-authenticated /api/webhooks/stripe reusing the issue-#3 seam, and operator key-issuance routes; compound assembly guard replaces the billing operator before_request with identical operator-route semantics so scoped client endpoints are reachable and valid client keys get 403 on operator routes (cross-tenant + operator denials). - auth.py: PUBLIC_PATHS + /api/webhooks/stripe; FORBIDDEN_BODY/ forbidden_response; hash_client_key (no new compare_digest call site). - schema.sql: client_api_keys table (hash + prefix only). - app.py: mounts the client surface (stripe_builder/gitea injectable). - tests/test_client_api.py (18 tests) + assembled-app coverage in test_app.py. Full suite: 293 passed, 1 skipped. --- gitea_saas/app.py | 42 ++- gitea_saas/auth.py | 87 ++++- gitea_saas/client_api.py | 672 +++++++++++++++++++++++++++++++++++++++ gitea_saas/schema.sql | 18 ++ tests/test_app.py | 118 ++++++- tests/test_client_api.py | 551 ++++++++++++++++++++++++++++++++ 6 files changed, 1467 insertions(+), 21 deletions(-) create mode 100644 gitea_saas/client_api.py create mode 100644 tests/test_client_api.py diff --git a/gitea_saas/app.py b/gitea_saas/app.py index 3af657a..b4e14aa 100644 --- a/gitea_saas/app.py +++ b/gitea_saas/app.py @@ -6,6 +6,7 @@ exactly one place: billing API ``gitea_saas.server.create_app`` (issues #1/#2/#6) factory ``gitea_saas.intake_factory.register_factory_routes`` (#4) dashboard ``gitea_saas.dashboard.mount_dashboard`` (issue #9) + client API ``gitea_saas.client_api.register_client_routes`` (#41) Assembly only: this module makes no behaviour changes to billing, factory, dashboard or review. If composing them ever surfaces a genuine @@ -26,17 +27,20 @@ from __future__ import annotations import tempfile from pathlib import Path from types import SimpleNamespace -from typing import Optional +from typing import Callable, Optional from flask import Flask, jsonify, request from .auth import PUBLIC_PATHS from .billing_core import BillingCore +from .client_api import (ClientKeyStore, ClientStatus, ClientSurface, + register_client_routes) from .config import ServerConfig, load_config from .dashboard import mount_dashboard from .gitea_client import GiteaClient from .intake_factory import register_factory_routes from .server import create_app as create_billing_app +from .stripe_client import build_stripe_client # Canonical body for the unconfigured-deployment gate (status 503). Same # ``{"error": {"code", "message"}}`` shape as ``auth.UNAUTHORIZED_BODY`` so @@ -90,18 +94,28 @@ def _unconfigured_guard(configured: bool): return _guard -def create_app(config: Optional[ServerConfig] = None) -> Flask: - """Build the ONE Flask app: billing + factory + dashboard mounted. +def create_app(config: Optional[ServerConfig] = None, *, + stripe_builder: Optional[Callable[[], object]] = None, + gitea: Optional[object] = None) -> Flask: + """Build the ONE Flask app: billing + factory + dashboard + client surface. ``config`` is a :class:`~gitea_saas.config.ServerConfig` (or any object exposing ``api_key`` / ``port`` / ``db_path``); when None the environment is loaded through the existing ``config.load_config`` loader (no new env vars). + ``stripe_builder`` is a zero-arg callable returning a Stripe seam (mock + or real) for the client surface (issue #41); it defaults to the + env-driven ``stripe_client.build_stripe_client``, which is fail-closed: + with no credentials and no explicit ``SAAS_STRIPE_MODE=mock`` it raises, + the surface answers checkout/webhook with 503 ``stripe_unconfigured``, + and the app still starts. ``gitea`` defaults to a real ``GiteaClient`` + (factory + client work-submission seam). + The DB is opened via ``gitea_saas.db`` (through ``BillingCore``, which owns the connection + idempotent schema bootstrap) and shared by all - three front-ends. With no ``SAAS_API_KEY`` the app still starts; - authed routes return 503 until a key is configured (module docstring). + front-ends. With no ``SAAS_API_KEY`` the app still starts; authed + routes return 503 until a key is configured (module docstring). """ cfg = config if config is not None else load_config() api_key = getattr(cfg, "api_key", None) or "" @@ -114,10 +128,26 @@ def create_app(config: Optional[ServerConfig] = None) -> Flask: # raise "IntakeFactory requires config.db_path" for the no-env case. runtime = SimpleNamespace(db_path=db_path, api_key=api_key) + gitea_client = gitea if gitea is not None else GiteaClient() + app = create_billing_app(core, api_key) - register_factory_routes(app, core, GiteaClient(), runtime) + register_factory_routes(app, core, gitea_client, runtime) mount_dashboard(app, core=core, db_path=db_path, api_key=api_key) + # Issue #41 client surface: per-client keys, checkout, /api/me, + # self-serve work submission and the Stripe webhook. Mounting it + # replaces the billing app's operator-key before_request with the + # compound assembly guard (see client_api.register_client_routes) whose + # operator-route semantics are identical — with the scoped client + # endpoints reachable and valid client keys answered 403 on operator + # routes (never 401, never passed through). + key_store = ClientKeyStore(db_path) + status = ClientStatus(core, db_path, + getattr(cfg, "debit_floor_minor", None)) + surface = ClientSurface(core, key_store, status, + stripe_builder or build_stripe_client) + register_client_routes(app, surface, gitea_client, runtime) + guard = _unconfigured_guard(bool(api_key)) if guard is not None: # Global before_request funcs run in list order; position 0 puts diff --git a/gitea_saas/auth.py b/gitea_saas/auth.py index 913041c..94a3b51 100644 --- a/gitea_saas/auth.py +++ b/gitea_saas/auth.py @@ -1,21 +1,22 @@ """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. +``dashboard.py``, ``client_api.py``) delegates to this module; the header name, the +constant-time comparison and the 401 body live exactly once here (no front-end forks them). -``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. +``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 signature check. Public surface: API_KEY_HEADER the only ``X-API-Key`` header literal in the package - PUBLIC_PATHS deliberately unauthenticated liveness/health routes + PUBLIC_PATHS deliberately unauthenticated liveness/health routes + the Stripe webhook (#41) 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 + FORBIDDEN_BODY canonical 403 payload (issue #41 cross-tenant denial) + forbidden_response() Flask Response object carrying that body + hash_client_key() SHA-256 hash for per-client keys (#41) — stored, never plaintext """ from __future__ import annotations @@ -23,19 +24,18 @@ 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). +# The one place the HTTP header literal lives (acceptance criterion 2: one literal). 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. +# Routes that need no operator API key; every front-end guard reads this set, +# so exposure is declared here, not inside one PR's before_request (composition +# finding 2026-08-26): /health, /healthz, /api/factory/health are liveness +# routes; /api/webhooks/stripe (#41) is signature-authenticated. PUBLIC_PATHS = frozenset({ "/health", "/healthz", "/api/factory/health", + "/api/webhooks/stripe", }) # Canonical 401 payload. All front-ends return exactly this (Flask @@ -77,3 +77,62 @@ def unauthorized_response(): resp = jsonify(UNAUTHORIZED_BODY) resp.status_code = 401 return resp + + +# --------------------------------------------------------------------------- +# Issue #41 additions — the client-facing surface's auth pieces. +# +# Deliberately placed BELOW key_is_valid/unauthorized_response so the issue +# #24 acceptance pin (exactly one compare_digest call site in this file, at +# auth.py:70) is untouched: per-client key verification adds NO comparison +# call site. A presented client key is hashed (hash_client_key) and resolved +# by indexed equality on the full 64-char digest in the client_api_keys +# table (client_api.ClientKeyStore.resolve_client_id). The digest is +# one-way, so even a timing leak on digest equality reveals nothing about +# the key — the discipline that matters is: full-length digest, and no +# plaintext ever stored or compared. +# --------------------------------------------------------------------------- + +# Canonical 403 payload (issue #41): a VALID key presented against a route +# its tenant is not allowed to reach (another client's resources, or an +# operator-only route). Distinguished from 401 (no/wrong key) so a client +# can tell "authenticate" from "authenticated but forbidden". +FORBIDDEN_BODY = { + "error": { + "code": "FORBIDDEN", + "message": "this key is not authorized for this resource", + }, +} + + +def forbidden_response(): + """Flask Response with the canonical 403 body (status 403, issue #41). + + Used when a key is VALID but its tenant is not allowed on the requested + route: another client's resources or an operator-only route. The body + is byte-identical on every front-end so a client parses it once. + """ + from flask import jsonify + + resp = jsonify(FORBIDDEN_BODY) + resp.status_code = 403 + return resp + + +CLIENT_KEY_HASH_ALGORITHM = "sha256" + + +def hash_client_key(raw_key: str) -> str: + """SHA-256 hex digest of a per-client API key (issue #41). + + The ONLY place a client key is hashed. ``raw_key`` must be a + non-empty str (a non-str raises ``TypeError`` from ``hashlib`` — the + caller validates before hashing). + """ + import hashlib + + if not isinstance(raw_key, str) or not raw_key: + raise ValueError("client key must be a non-empty string") + digest = hashlib.new(CLIENT_KEY_HASH_ALGORITHM) + digest.update(raw_key.encode("utf-8")) + return digest.hexdigest() diff --git a/gitea_saas/client_api.py b/gitea_saas/client_api.py new file mode 100644 index 0000000..67806bb --- /dev/null +++ b/gitea_saas/client_api.py @@ -0,0 +1,672 @@ +"""Client-facing HTTP surface (issue #41): self-serve funding + work submission. + +Until this module there was exactly one credential — the shared operator +``SAAS_API_KEY`` — and every route was operator/admin. This module adds the +first genuinely externally-reachable surface: + +* **Per-client API keys** — ``ClientKeyStore``. Keys are random, scoped to + exactly one ``client_id``, stored ONLY as a SHA-256 hash (the plaintext is + returned exactly once, at issuance, then never seen again) and revocable. + The hash function lives in ``gitea_saas.auth`` (issue #41 extension) and + verification is digest-equality against the key table — this module does + not fork auth, it builds on it. +* **Checkout** — ``POST /api/checkout`` returns a Stripe Checkout URL for a + top-up, rate-limited per client. The webhook below is the ONLY credit + path: ``stripe_client.apply_payment_event`` (the issue-#3 seam) is reused, + never a second credit implementation. +* **Client status** — ``GET /api/me`` returns the caller's own balance, + queue counts, in-flight count and delivered count (scoped to the caller's + client_id by construction). +* **Work submission** — ``POST /api/me/issues`` submits work as the caller's + OWN client_id through the issue-#4 factory (funded -> Gitea issue, + unfunded/locked -> queued). A client never supplies another tenant's id. +* **Stripe webhook** — ``POST /api/webhooks/stripe``, authenticated by + Stripe's signature (never by an API key), credits idempotently. + +Tenancy & guard composition (the subtle part) +--------------------------------------------- +``server.create_app`` installs an operator-key ``before_request`` guard that +401s every non-``PUBLIC_PATHS`` route — it would make the client surface +unreachable, since client routes are authenticated by a DIFFERENT key class. +Mounting the client surface therefore REPLACES that single global guard +with a compound assembly guard (registered in ``register_client_routes``); +operator-route behaviour is preserved EXACTLY (same ``PUBLIC_PATHS`` set, +same ``auth.key_is_valid``, same canonical 401): + +1. ``PUBLIC_PATHS`` (liveness + the signature-authenticated webhook) pass + straight through. +2. A valid client key on a ``CLIENT_PATHS`` route is stored in + ``flask.g.client_id`` and allowed. +3. A valid client key ANYWHERE ELSE (``/api/clients``, ``/api/factory/*``, + ``/dashboard``, key issuance) is forbidden: canonical 403 body. +4. No/invalid client key: client-surface routes get the canonical 401; + operator routes fall through to the operator check, which decides + exactly as before (the unconfigured-deployment 503 gate from + ``app.create_app`` runs ahead of everything and still short-circuits + every non-public route first). + +Consequences that are deliberate: +* The operator key never works on the client surface (401), and a client key + never works on operator routes (403). +* The operator key-issuance routes (``/api/clients//keys``) are NOT part + of the client surface — they stay behind the operator guard, and a client + key hitting them gets 403. +* The existing ``/api/factory//*`` routes stay operator-only. Clients + submit work through ``/api/me/issues`` instead, so the factory module is + untouched and its per-route operator check cannot be bypassed. + +Fail-closed anywhere a secret could be at stake: an unconfigured Stripe +deployment answers checkout/webhook with 503 ``stripe_unconfigured`` (never +a fallback secret, never a 200); nothing in this module ever logs a key, a +Stripe secret, or a webhook payload. + +Hermetic by construction (same convention as the other front-ends): the +billing core, key store and status provider are injected; the Stripe client +comes from an injected builder (the mock is selected only on explicit opt-in +per ``stripe_client.build_stripe_client``). +""" + +from __future__ import annotations + +import os +import re +import secrets +import sqlite3 +import threading +import time +from collections import deque +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +from flask import Flask, g, jsonify, request + +from .auth import (API_KEY_HEADER, PUBLIC_PATHS, forbidden_response, + hash_client_key, key_is_valid, unauthorized_response) +from .intake_factory import (CoreContractError, IntakeFactory, + InvalidRequestError, UnknownClientError) + +# Canonical prefix for generated client keys (display-friendly, never used +# for auth — the full key is required and only its hash is stored). +CLIENT_KEY_PREFIX = "saas_" +CLIENT_KEY_BYTES = 32 + +# The routes that belong to a client's OWN surface (self-scoped by the +# caller's key). A valid client key is allowed here and only here; on every +# other route it is forbidden (403). Keep in sync with the routes below. +CLIENT_PATHS = frozenset({ + "/api/checkout", + "/api/me", + "/api/me/issues", +}) + +STRIPE_WEBHOOK_ROUTE = "/api/webhooks/stripe" + +# Rate limit for POST /api/checkout per client (fixed per-client window). +DEFAULT_CHECKOUT_RATE_LIMIT = 5 +DEFAULT_CHECKOUT_RATE_WINDOW_S = 60 + +# Mirror of intake_factory's repo-name rule (issue #4 contract). +_REPO_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$") + +# Idempotent DDL mirror of the schema.sql addition (same convention as +# intake_factory._FACTORY_DDL: this module can be constructed against a +# fresh file even before the billing core has run schema.sql). +_KEYS_DDL = """ +CREATE TABLE IF NOT EXISTS client_api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id), + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_client_api_keys_client ON client_api_keys (client_id); +CREATE INDEX IF NOT EXISTS idx_client_api_keys_hash ON client_api_keys (key_hash); +""" + + +def _json_error(status: int, code: str, message: str): + """Canonical structured error (same shape as auth.UNAUTHORIZED_BODY).""" + resp = jsonify({"error": {"code": code, "message": message}}) + resp.status_code = status + return resp + + +class _RateLimiter: + """Sliding-window request limiter, keyed by tenant (in-process). + + Single-process service (gunicorn sync workers run one app instance per + worker); the limiter is per-process by design. ``allow(key)`` returns + ``(allowed, retry_after_s)`` — retry_after is 0 when allowed. + """ + + def __init__(self, limit: int, window_s: float): + self._limit = max(int(limit), 1) + self._window_s = float(window_s) + self._hits: Dict[Any, deque] = {} + self._lock = threading.Lock() + + def allow(self, key: Any) -> tuple[bool, float]: + now = time.monotonic() + with self._lock: + dq = self._hits.setdefault(key, deque()) + while dq and now - dq[0] >= self._window_s: + dq.popleft() + if len(dq) >= self._limit: + retry = self._window_s - (now - dq[0]) + return False, max(retry, 1.0) + dq.append(now) + return True, 0.0 + + +class ClientKeyStore: + """Per-client API keys on the shared SQLite file: issue/list/revoke/resolve. + + At-rest policy (acceptance criterion 2): ONLY ``hash_client_key`` output + is stored — the plaintext is returned once at issuance and never written. + A resolver looks the presented key up by its hash; a revoked or deleted + client's keys stop resolving. ``list()`` returns metadata only (id, + prefix, timestamps) — never hashes, never plaintext. + """ + + def __init__(self, db_path): + self._conn = sqlite3.connect(str(Path(db_path)), check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA foreign_keys=ON") + self._conn.executescript(_KEYS_DDL) + self._lock = threading.RLock() + + # -- helpers ---------------------------------------------------------- + + def _client_exists(self, client_id: int) -> bool: + row = self._conn.execute( + "SELECT 1 FROM clients WHERE id = ?", (client_id,)).fetchone() + return row is not None + + @staticmethod + def _generate() -> str: + return CLIENT_KEY_PREFIX + secrets.token_urlsafe(CLIENT_KEY_BYTES) + + # -- operations ------------------------------------------------------- + + def issue(self, client_id: int) -> tuple[bool, dict]: + """Create a key for ``client_id``; plaintext returned ONCE. + + Returns ``(True, {"key_id", "client_id", "key", "prefix", + "created_at"})`` or ``(False, {"error": ...})`` for an unknown + client. The plaintext ``key`` is not stored and cannot be recovered. + """ + if isinstance(client_id, bool) or not isinstance(client_id, int) \ + or client_id <= 0: + return False, {"error": {"code": "UNKNOWN_CLIENT", + "message": "client_id must be a positive integer"}} + with self._lock: + if not self._client_exists(client_id): + return False, {"error": {"code": "UNKNOWN_CLIENT", + "message": f"no client with id {client_id}"}} + raw = self._generate() + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + cur = self._conn.execute( + "INSERT INTO client_api_keys (client_id, key_hash, key_prefix," + " created_at) VALUES (?, ?, ?, ?)", + (client_id, hash_client_key(raw), raw[:10], now)) + self._conn.commit() + return True, {"key_id": cur.lastrowid, "client_id": client_id, + "key": raw, "prefix": raw[:10], "created_at": now} + + def list_keys(self, client_id: int) -> tuple[bool, dict]: + """Key metadata for a client (never hashes, never plaintext).""" + with self._lock: + if not self._client_exists(client_id): + return False, {"error": {"code": "UNKNOWN_CLIENT", + "message": f"no client with id {client_id}"}} + rows = self._conn.execute( + "SELECT id, key_prefix, created_at, revoked_at" + " FROM client_api_keys WHERE client_id = ? ORDER BY id", + (client_id,)).fetchall() + keys = [{"key_id": int(r["id"]), "prefix": r["key_prefix"], + "created_at": r["created_at"], "revoked_at": r["revoked_at"]} + for r in rows] + return True, {"client_id": client_id, "keys": keys, "count": len(keys)} + + def revoke(self, client_id: int, key_id: int) -> tuple[bool, dict]: + """Revoke exactly one key that BELONGS to ``client_id`` (scoped).""" + with self._lock: + if not self._client_exists(client_id): + return False, {"error": {"code": "UNKNOWN_CLIENT", + "message": f"no client with id {client_id}"}} + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + cur = self._conn.execute( + "UPDATE client_api_keys SET revoked_at = ?" + " WHERE id = ? AND client_id = ? AND revoked_at IS NULL", + (now, key_id, client_id)) + self._conn.commit() + if cur.rowcount == 0: + return False, {"error": {"code": "KEY_NOT_FOUND", + "message": "no active key with that id for this client"}} + return True, {"key_id": key_id, "client_id": client_id, + "revoked_at": now, "revoked": True} + + def revoke_all(self, client_id: int) -> tuple[bool, dict]: + """Revoke every active key of ``client_id`` (idempotent).""" + with self._lock: + if not self._client_exists(client_id): + return False, {"error": {"code": "UNKNOWN_CLIENT", + "message": f"no client with id {client_id}"}} + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + cur = self._conn.execute( + "UPDATE client_api_keys SET revoked_at = ?" + " WHERE client_id = ? AND revoked_at IS NULL", + (now, client_id)) + self._conn.commit() + return True, {"client_id": client_id, "revoked_count": cur.rowcount} + + def resolve_client_id(self, provided_key: Optional[str]) -> Optional[int]: + """The client_id owning ``provided_key``, or None (fail-closed). + + Hashes the presented key and looks the row up by hash; revoked keys + and keys whose client no longer exists never resolve. Returns None + for a missing/malformed key, a revoked key, or an unknown client. + """ + if not isinstance(provided_key, str) or not provided_key: + return None + try: + digest = hash_client_key(provided_key) + except ValueError: + return None + try: + row = self._conn.execute( + "SELECT k.client_id FROM client_api_keys k" + " JOIN clients c ON c.id = k.client_id" + " WHERE k.key_hash = ? AND k.revoked_at IS NULL", + (digest,)).fetchone() + except sqlite3.Error: + return None + return int(row["client_id"]) if row is not None else None + + +class ClientStatus: + """Client-visible status: balance + queue/in-flight/delivered counts. + + Reads the shared SQLite file like ``Dashboard`` does (its own read + connection); balance comes from the billing core (single source of + truth, holds included). Definitions (documented in the API): + + * ``queued_items`` — ``pending_issues`` rows still waiting in the + intake queue (unfunded/locked submits, issue #4). + * ``in_flight_items`` — ``pending_issues`` rows the factory has marked + ``shipped`` (flushed from the queue into Gitea, awaiting + merge/delivery). A direct funded submit is created in Gitea without + a local row and is therefore not counted here — the factory tracks + neither it nor a delivery-complete state, so this count is defined by + the data the system records. + * ``delivered_count`` — distinct delivery debits in the ledger + (``ref_type`` ``pr``/``delivery`` — the only DB record of a delivery). + """ + + DEFAULT_DEBIT_FLOOR_MINOR = 500 + + def __init__(self, core: Any, db_path, debit_floor_minor: Optional[int] = None): + self._core = core + self._conn = sqlite3.connect(str(Path(db_path))) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + if debit_floor_minor is None: + raw = os.environ.get("SAAS_DEBIT_FLOOR_MINOR", "").strip() + debit_floor_minor = int(raw) if raw else self.DEFAULT_DEBIT_FLOOR_MINOR + self._floor = debit_floor_minor + + def _table_exists(self, name: str) -> bool: + row = self._conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + (name,)).fetchone() + return row is not None + + def summary(self, client_id: int) -> tuple[bool, dict]: + ok, details = self._core.get_client(client_id, include_balance=True) + if not ok or not isinstance(details, dict): + return False, details if isinstance(details, dict) \ + else {"error": {"code": "UNKNOWN_CLIENT", + "message": f"no client with id {client_id}"}} + cid = int(details.get("id", client_id)) + balance = details.get("balance") + locked = False + if self._table_exists("factory_locks"): + try: + locked = self._conn.execute( + "SELECT 1 FROM factory_locks WHERE client_id = ?", + (cid,)).fetchone() is not None + except Exception: + locked = False + if locked: + status = "locked" + elif isinstance(balance, int) and balance < self._floor: + status = "overdue" + else: + status = str(details.get("status") or "active") + + queued = in_flight = delivered = 0 + if self._table_exists("pending_issues"): + try: + row = self._conn.execute( + "SELECT status, COUNT(*) AS n FROM pending_issues" + " WHERE client_id = ? GROUP BY status", (cid,)).fetchall() + counts = {str(r["status"]): int(r["n"]) for r in row} + queued = counts.get("queued", 0) + in_flight = counts.get("shipped", 0) + except Exception: + queued = in_flight = 0 + try: + delivered = int(self._conn.execute( + "SELECT COUNT(DISTINCT ref_id) FROM ledger" + " WHERE client_id = ? AND kind = 'debit'" + " AND ref_type IN ('pr', 'delivery') AND ref_id IS NOT NULL", + (cid,)).fetchone()[0]) + except Exception: + delivered = 0 + return True, { + "client_id": cid, + "name": details.get("name"), + "tier": details.get("tier"), + "status": status, + "balance_minor": balance, + "currency": "USD", + "queued_items": queued, + "in_flight_items": in_flight, + "delivered_count": delivered, + } + + +class ClientSurface: + """State shared by the client routes: guard, keys, status, Stripe. """ + + def __init__(self, core: Any, key_store: ClientKeyStore, + status: ClientStatus, + stripe_builder: Callable[[], Any], + checkout_rate: int = DEFAULT_CHECKOUT_RATE_LIMIT, + checkout_window_s: float = DEFAULT_CHECKOUT_RATE_WINDOW_S): + self.core = core + self.keys = key_store + self.status = status + self.stripe_builder = stripe_builder + self._limiter = _RateLimiter(checkout_rate, checkout_window_s) + + # -- request-time helpers -------------------------------------------- + + def stripe(self) -> Optional[Any]: + """A usable Stripe client, or None when unconfigured (fail-closed). + + The builder is env-driven (``stripe_client.build_stripe_client`` by + default): a deployment without Stripe credentials, or without an + EXPLICIT mock opt-in, gets None here — the routes answer 503, never + a fallback secret and never a 200 with data. + """ + try: + return self.stripe_builder() + except Exception: + return None + + def limiter_allow(self, client_id: int) -> tuple[bool, float]: + return self._limiter.allow(client_id) + + # -- the tenant-aware decision (used by the compound assembly guard) --- + + def check_client_key(self, path: str) -> Optional[tuple]: + """Decide the client-surface part of a request's auth. + + Returns ``None`` when the request is not a client-surface matter + (operator routes are left to the operator check), or a tuple: + + * ``("forbid", Flask-Response)`` — a VALID client key presented + outside its own surface (403, cross-tenant / operator route), or + a client-surface path without a valid client key (401); + * ``("allowed", client_id)`` — a valid client key on its own + surface; the caller stores the client_id for the route. + """ + provided = request.headers.get(API_KEY_HEADER, "") + client_id = self.keys.resolve_client_id(provided) + if client_id is not None: + if path in CLIENT_PATHS: # the key's own surface + return ("allowed", client_id) + return ("forbid", forbidden_response()) # wrong tenant/route + if path in CLIENT_PATHS: # client surface needs a client key + return ("forbid", unauthorized_response()) + return None # operator routes: their check decides + + +def _map_key_store_error(details: dict): + code = (details.get("error") or {}).get("code") + message = (details.get("error") or {}).get("message") or "key operation failed" + if code == "UNKNOWN_CLIENT": + return _json_error(404, "unknown_client", message) + if code == "KEY_NOT_FOUND": + return _json_error(404, "key_not_found", message) + return _json_error(500, "key_store_error", message) + + +def _map_factory_error(exc: Exception): + """Map IntakeFactory domain errors (mirror of intake_factory's mapper).""" + if isinstance(exc, UnknownClientError): + return _json_error(404, "unknown_client", str(exc)) + if isinstance(exc, (InvalidRequestError, ValueError)): + return _json_error(400, "invalid_request", str(exc)) + if isinstance(exc, CoreContractError): + return _json_error(500, "core_contract", str(exc)) + return _json_error(500, "factory_error", f"{type(exc).__name__}: {exc}") + + +def register_client_routes(app: Flask, surface: ClientSurface, + gitea: Any, config: Any) -> Flask: + """Mount the client-facing surface on an existing Flask app. + + ``surface`` carries core/keys/status/Stripe; ``gitea`` and ``config`` + feed the per-request ``IntakeFactory`` for ``/api/me/issues`` (same + seam the issue-#4 factory routes use). + + Registers the tenant-aware guard AHEAD of every other before_request + (including the operator-key guard installed by ``server.create_app``) — + see the module docstring: valid client keys must be answered 403 on + operator routes, and the client surface must be reachable even though + the operator guard would otherwise 401 it. + """ + core = surface.core + + # -- the tenant-aware guard -------------------------------------------- + # server.create_app installs an operator-key before_request that 401s + # every non-PUBLIC_PATHS route — including the client surface, whose + # auth is a DIFFERENT key class. Mounting the client surface therefore + # REPLACES that single global guard with a compound assembly guard: + # 1. PUBLIC_PATHS (liveness + the signature-authenticated webhook) + # pass straight through; + # 2. the client-surface decision wins when it is one (403 for a + # valid client key off its surface / on operator routes, 401 on + # the client surface without a valid key); + # 3. otherwise the operator check runs with EXACTLY server.create_app's + # semantics (same PUBLIC_PATHS set, same auth.key_is_valid, same + # canonical 401 body) — no behaviour change for operator routes. + # The unconfigured-deployment 503 gate (app.py) is inserted at position + # 0 AFTER this, so it still short-circuits everything non-public first. + + app.before_request_funcs.setdefault(None, []).clear() + + def _assembly_guard(): + path = request.path.rstrip("/") + if path in PUBLIC_PATHS: + return None + decision = surface.check_client_key(path) + if decision is not None: + outcome, payload = decision + if outcome == "forbid": + return payload # 403 / 401 decision (see above) + g.client_id = payload # allowed on the client's own surface + return None + # Operator routes: server.create_app's check, preserved verbatim. + sent = request.headers.get(API_KEY_HEADER, "") + expected = app.config.get("API_KEY", "") + if key_is_valid(sent, expected): + return None + return unauthorized_response() + + app.before_request_funcs.setdefault(None, []).insert(0, _assembly_guard) + + # -- self-serve checkout --------------------------------------------- + + @app.post("/api/checkout") + def checkout(): + client_id = g.get("client_id") + if client_id is None: + return unauthorized_response() + allowed, retry_after = surface.limiter_allow(client_id) + if not allowed: + resp = _json_error(429, "rate_limited", + "too many checkout requests; retry shortly") + resp.headers["Retry-After"] = str(int(retry_after)) + return resp + body = request.get_json(silent=True) + if not isinstance(body, dict): + return _json_error(400, "invalid_request", + "request body must be a JSON object") + amount = body.get("amount_minor") + if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0: + return _json_error(400, "invalid_request", + "'amount_minor' must be a positive integer (minor units)") + currency = body.get("currency") or "usd" + if (not isinstance(currency, str) or len(currency) != 3 + or not currency.isalpha()): + return _json_error(400, "invalid_request", + "'currency' must be a 3-letter code") + success_url = body.get("success_url") + cancel_url = body.get("cancel_url") + for name, value in (("success_url", success_url), + ("cancel_url", cancel_url)): + if value is not None and ( + not isinstance(value, str) + or not re.match(r"^https?://", value.strip())): + return _json_error(400, "invalid_request", + f"'{name}' must be an absolute http(s) URL") + stripe = surface.stripe() + if stripe is None: + return _json_error(503, "stripe_unconfigured", + "Stripe is not configured on this deployment (fail-closed)") + try: + result = stripe.create_checkout_session( + client_id, amount, currency.lower(), + success_url.strip() if isinstance(success_url, str) else None, + cancel_url.strip() if isinstance(cancel_url, str) else None) + except Exception: + return _json_error(503, "stripe_error", + "checkout session could not be created") + resp = jsonify(result) + resp.status_code = 201 + return resp + + # -- Stripe webhook (signature-authenticated; NOT an API-key route) --- + + @app.post(STRIPE_WEBHOOK_ROUTE) + def stripe_webhook(): + stripe = surface.stripe() + if stripe is None: + return _json_error(503, "stripe_unconfigured", + "Stripe is not configured on this deployment (fail-closed)") + payload = request.get_data() + signature = request.headers.get("Stripe-Signature", "") + try: + event = stripe.process_webhook(payload, signature) + except Exception: + # Signature failures and malformed payloads are indistinguishable + # on purpose: never leak whether the signature was the problem. + return _json_error(400, "invalid_signature", + "webhook signature verification failed") + from .stripe_client import apply_payment_event + ok, details = apply_payment_event(core, event) + if not ok: + message = ((details or {}).get("error") or {}).get("message") \ + or "event could not be applied" + return _json_error(400, "event_rejected", message) + # Success path: idempotent credit via the issue-#3 seam. The full + # payload is never logged and never echoed. + return jsonify({"received": True, + "applied": bool(details.get("applied", True)), + "event_id": event.get("event_id")}) + + # -- client status & self-serve work submission ----------------------- + + @app.get("/api/me") + def me(): + client_id = g.get("client_id") + if client_id is None: + return unauthorized_response() + ok, details = surface.status.summary(client_id) + if not ok: + return _json_error(404, "unknown_client", + f"no client with id {client_id}") + return jsonify(details) + + @app.post("/api/me/issues") + def me_submit_issue(): + client_id = g.get("client_id") + if client_id is None: + return unauthorized_response() + body = request.get_json(silent=True) + if not isinstance(body, dict): + return _json_error(400, "invalid_request", + "request body must be a JSON object") + repo = (body.get("repo") or "").strip() + title = (body.get("title") or "").strip() + acceptance_body = str(body.get("acceptance_body") or "") + labels = body.get("labels") + if not isinstance(labels, list): + labels = None + labels = [str(label) for label in labels] if labels else None + if not _REPO_NAME_RE.match(repo): + return _json_error(400, "invalid_request", + f"invalid repo name: {repo!r}") + if not title: + return _json_error(400, "invalid_request", + "issue title must be a non-empty string") + try: + factory = IntakeFactory(core, gitea, config) + result = factory.submit_issue(client_id, repo, title, + acceptance_body, labels) + except Exception as exc: + return _map_factory_error(exc) + resp = jsonify(result) + resp.status_code = 202 if not result.get("delivered") else 201 + return resp + + # -- operator-only key management (behind the operator guard) --------- + # These are NOT client-surface routes: the tenant-aware guard above + # forbids a client key here (403), and the operator guard requires the + # shared SAAS_API_KEY. + + @app.post("/api/clients//keys") + def issue_client_key(client_id: int): + ok, details = surface.keys.issue(client_id) + if not ok: + return _map_key_store_error(details) + resp = jsonify(details) + resp.status_code = 201 + return resp + + @app.get("/api/clients//keys") + def list_client_keys(client_id: int): + ok, details = surface.keys.list_keys(client_id) + if not ok: + return _map_key_store_error(details) + return jsonify(details) + + @app.delete("/api/clients//keys/") + def revoke_client_key(client_id: int, key_id: int): + ok, details = surface.keys.revoke(client_id, key_id) + if not ok: + return _map_key_store_error(details) + return jsonify(details) + + @app.delete("/api/clients//keys") + def revoke_all_client_keys(client_id: int): + ok, details = surface.keys.revoke_all(client_id) + if not ok: + return _map_key_store_error(details) + return jsonify(details) + + return app diff --git a/gitea_saas/schema.sql b/gitea_saas/schema.sql index 54a5f99..2b24b4a 100644 --- a/gitea_saas/schema.sql +++ b/gitea_saas/schema.sql @@ -42,6 +42,24 @@ CREATE TABLE IF NOT EXISTS holds ( released_at TEXT ); +-- Per-client API keys (issue #41): the client-facing surface's credentials. +-- Only the SHA-256 hash of a key is stored -- never the plaintext. A row is +-- scoped to exactly one client, so a key can only ever resolve to its owner's +-- client_id (cross-tenant denial is enforced by the client surface using this +-- scope). revoked_at NULL = active; a revoked key fails resolution and is +-- never returned by any listing (prefix + created_at only, for display). +CREATE TABLE IF NOT EXISTS client_api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id), + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + revoked_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_client_api_keys_client ON client_api_keys (client_id); +CREATE INDEX IF NOT EXISTS idx_client_api_keys_hash ON client_api_keys (key_hash); + -- Balance / listing lookups. CREATE INDEX IF NOT EXISTS idx_ledger_client_created ON ledger (client_id, created_at, id); CREATE INDEX IF NOT EXISTS idx_ledger_client_kind ON ledger (client_id, kind); diff --git a/tests/test_app.py b/tests/test_app.py index 6d152ac..e9020e3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -21,7 +21,8 @@ import pytest from gitea_saas import app as app_module from gitea_saas.app import create_app -from gitea_saas.auth import API_KEY_HEADER, UNAUTHORIZED_BODY +from gitea_saas.auth import (API_KEY_HEADER, FORBIDDEN_BODY, + UNAUTHORIZED_BODY) from gitea_saas.config import ServerConfig API_KEY = "test-api-key-secret" @@ -86,6 +87,20 @@ def test_url_map_contains_dashboard_and_liveness_routes(app): assert expected in rules, f"missing route {expected}" +def test_url_map_contains_client_surface_routes(app): + """Issue #41: the assembled app mounts the whole client surface.""" + rules = _rules(app) + for expected in ( + "/api/checkout", + "/api/me", + "/api/me/issues", + "/api/webhooks/stripe", + "/api/clients//keys", + "/api/clients//keys/", + ): + assert expected in rules, f"missing client route {expected}" + + # --------------------------------------------------------------------------- # Acceptance 2: no SAAS_API_KEY -> app starts, authed routes fail closed 503 # --------------------------------------------------------------------------- @@ -104,6 +119,13 @@ def test_unset_api_key_fails_closed_503(tmp_path, monkeypatch): assert c.get("/api/dashboard/summary").status_code == 503 assert c.post("/api/factory/provision", json={"client_id": 1}).status_code == 503 + # issue #41: the client surface is authed too -> 503, not 200. + assert c.get("/api/me").status_code == 503 + assert c.post("/api/checkout", json={"amount_minor": 100}).status_code == 503 + assert c.post("/api/me/issues", json={}).status_code == 503 + # ...but the signature-authenticated webhook is a PUBLIC_PATHS route + # (no Stripe configured here -> fail-closed 503, never a 200). + assert c.post("/api/webhooks/stripe", data=b"{}").status_code == 503 # liveness routes in auth.PUBLIC_PATHS stay reachable without a key for path in ("/health", "/healthz", "/api/factory/health"): @@ -147,6 +169,100 @@ def test_wrong_key_is_canonical_401(client): assert resp.get_json() == UNAUTHORIZED_BODY +# --------------------------------------------------------------------------- +# Issue #41 through the REAL assembled app: authenticated client path, +# operator/cross-tenant denials, checkout -> webhook credit +# --------------------------------------------------------------------------- + +class _FakeGitea: + def create_org_repo(self, name): + pass + + def create_issue(self, repo, title, body, labels=None): + return 1 + + +def _make_client_app(tmp_path): + from gitea_saas.stripe_client import MockStripeClient + application = create_app( + _cfg(tmp_path), + stripe_builder=lambda: MockStripeClient(webhook_signature="test-sig"), + gitea=_FakeGitea()) + application.config.update(TESTING=True) + return application + + +def test_assembled_app_authenticated_client_path_and_denials(tmp_path): + """The assembled app serves the client surface and denies misuse. + + Acceptance: client A's key works on ITS OWN surface through the real + composed app; it gets the canonical 403 on /api/clients, on client B's + /api/factory/* resources and on the dashboard; the operator key never + reaches the client surface. Checkout -> signature-verified webhook -> + balance credit (idempotent) also runs through the assembled app. + """ + import json as _json + + application = _make_client_app(tmp_path) + c = application.test_client() + + def op_post(path, body=None): + return c.post(path, json=body or {}, headers=AUTH) + + # Provision + keys. + a_id = op_post("/api/clients", {"name": "AsmClientA"}).get_json()["id"] + b_id = op_post("/api/clients", {"name": "AsmClientB"}).get_json()["id"] + key_a = c.post(f"/api/clients/{a_id}/keys", + headers=AUTH).get_json()["key"] + key_b = c.post(f"/api/clients/{b_id}/keys", + headers=AUTH).get_json()["key"] + + # Client A's own surface through the assembled app. + me = c.get("/api/me", headers={API_KEY_HEADER: key_a}) + assert me.status_code == 200 + assert me.get_json()["client_id"] == a_id + + # Checkout -> webhook -> credit (idempotent), through the assembled app. + checkout = c.post("/api/checkout", + json={"amount_minor": 3000}, + headers={API_KEY_HEADER: key_a}) + assert checkout.status_code == 201 + event = {"id": "evt_asm_1", "type": "payment.succeeded", + "data": {"object": {"metadata": {"client_id": str(a_id)}, + "amount_total": 3000}}} + payload = _json.dumps(event).encode() + wh = c.post("/api/webhooks/stripe", data=payload, + content_type="application/json", + headers={"Stripe-Signature": "test-sig"}) + assert wh.status_code == 200 + me = c.get("/api/me", headers={API_KEY_HEADER: key_a}).get_json() + assert me["balance_minor"] == 3000 + replay = c.post("/api/webhooks/stripe", data=payload, + content_type="application/json", + headers={"Stripe-Signature": "test-sig"}) + assert replay.status_code == 200 + me = c.get("/api/me", headers={API_KEY_HEADER: key_a}).get_json() + assert me["balance_minor"] == 3000 # exactly once + + # Cross-tenant + operator denials (canonical 403 body). + for path in (f"/api/factory/{b_id}/lock", + f"/api/factory/{b_id}/issues", + f"/api/clients/{b_id}", + f"/api/clients/{b_id}/keys"): + resp = c.post(path, json={}, headers={API_KEY_HEADER: key_a}) + assert resp.status_code == 403, path + assert resp.get_json() == FORBIDDEN_BODY, path + assert c.get("/api/clients", + headers={API_KEY_HEADER: key_a}).status_code == 403 + assert c.get("/dashboard", + headers={API_KEY_HEADER: key_a}).status_code == 403 + # Client B still sees only itself. + assert c.get("/api/me", headers={API_KEY_HEADER: key_b}).get_json()[ + "client_id"] == b_id + # The operator key never works on the client surface. + assert c.get("/api/me", headers=AUTH).status_code == 401 + + # --------------------------------------------------------------------------- # Entrypoints exist and are wired to the same main() # --------------------------------------------------------------------------- diff --git a/tests/test_client_api.py b/tests/test_client_api.py new file mode 100644 index 0000000..327a7d8 --- /dev/null +++ b/tests/test_client_api.py @@ -0,0 +1,551 @@ +"""Hermetic tests for the client-facing surface (issue #41). + +Per-client API keys (hashed at rest, scoped, revocable), self-serve +checkout (rate-limited), the Stripe webhook credit path (idempotent via +the issue-#3 seam), ``GET /api/me`` client status, self-serve work +submission, and the cross-tenant / operator-route denial guarantees. + +Zero network: Flask test client + temp SQLite DB + MockStripeClient + +FakeGitea. Run with: + python -m pytest tests/test_client_api.py -q +""" + +import json +import sys +from pathlib import Path +from typing import Optional + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from gitea_saas.auth import (API_KEY_HEADER, FORBIDDEN_BODY, # noqa: E402 + UNAUTHORIZED_BODY, hash_client_key) +from gitea_saas.billing_core import BillingCore # noqa: E402 +from gitea_saas.client_api import (ClientKeyStore, ClientStatus, # noqa: E402 + ClientSurface, register_client_routes) +from gitea_saas.config import ServerConfig # noqa: E402 +from gitea_saas.stripe_client import MockStripeClient # noqa: E402 + +API_KEY = "operator-test-key-41" +AUTH = {API_KEY_HEADER: API_KEY} + +# Explicit mock signature: tests only, never a production default. +MOCK_SIG = "test-webhook-sig-41" + + +class FakeGitea: + """In-memory create_org_repo/create_issue seam (factory contract).""" + + def __init__(self): + self.repos = set() + self.issues = [] + + def create_org_repo(self, name): + self.repos.add(name) + + def create_issue(self, repo, title, body, labels=None): + number = len(self.issues) + 1 + self.issues.append({"repo": repo, "title": title, "body": body, + "labels": labels, "number": number}) + return number + + +def _mock_stripe_builder(): + return MockStripeClient(webhook_signature=MOCK_SIG) + + +def _cfg(tmp_path: Path, api_key: Optional[str] = API_KEY) -> ServerConfig: + return ServerConfig(api_key=api_key, port=8080, db_path=tmp_path / "saas.db") + + +def _webhook_payload(event_id="evt_41_1", client_id=1, amount=2500): + return {"id": event_id, "type": "payment.succeeded", + "data": {"object": {"metadata": {"client_id": str(client_id)}, + "amount_total": amount}}} + + +@pytest.fixture() +def client_factory(tmp_path): + """Build ONE assembled app per test (cached); returns a zero-arg builder.""" + state = {} + + def build(): + if "app" in state: + return state["app"], state["gitea"] + from gitea_saas.app import create_app + gitea = FakeGitea() + application = create_app(_cfg(tmp_path, API_KEY), + stripe_builder=_mock_stripe_builder, + gitea=gitea) + application.config.update(TESTING=True) + state["app"] = application + state["gitea"] = gitea + return application, gitea + + build() + return build + + +@pytest.fixture() +def client(client_factory): + application, _ = client_factory() + return application.test_client() + + +@pytest.fixture() +def gitea(client_factory): + return client_factory()[1] + + +# --------------------------------------------------------------------------- +# helpers: provision clients + issue keys through the operator API +# --------------------------------------------------------------------------- + +def _create_client(client, name: str) -> int: + resp = client.post("/api/clients", json={"name": name}, headers=AUTH) + assert resp.status_code == 201 + return resp.get_json()["id"] + + +def _issue_key(client, client_id: int) -> dict: + resp = client.post(f"/api/clients/{client_id}/keys", headers=AUTH) + assert resp.status_code == 201 + return resp.get_json() + + +def _client_header(raw_key: str) -> dict: + return {API_KEY_HEADER: raw_key} + + +# --------------------------------------------------------------------------- +# ClientKeyStore unit tests (acceptance criterion 2: hashed at rest) +# --------------------------------------------------------------------------- + +def test_keys_stored_hashed_plaintext_never_in_db(tmp_path): + core = BillingCore(tmp_path / "saas.db") + ok, rec = core.create_client("HashCo") + store = ClientKeyStore(tmp_path / "saas.db") + ok, issued = store.issue(rec["id"]) + assert ok + raw = issued["key"] + + # The plaintext key is not anywhere in the DB — not as a value, not as + # a column. The stored material is hash + display prefix only. + rows = store._conn.execute( + "SELECT key_hash, key_prefix FROM client_api_keys").fetchall() + assert len(rows) == 1 + stored_hash, stored_prefix = rows[0]["key_hash"], rows[0]["key_prefix"] + assert hash_client_key(raw) == stored_hash + assert stored_hash != raw + assert stored_prefix != raw # prefix is a truncated display hint + assert raw not in stored_hash + # Dump the whole DB as text: plaintext must not appear anywhere. + blob = " ".join(str(v) for row in store._conn.execute( + "SELECT * FROM client_api_keys") for v in row) + assert raw not in blob + + +def test_resolve_client_id_scoped_and_revoked(tmp_path): + core = BillingCore(tmp_path / "saas.db") + _, a = core.create_client("Alice") + _, b = core.create_client("Bob") + store = ClientKeyStore(tmp_path / "saas.db") + _, ka = store.issue(a["id"]) + _, kb = store.issue(b["id"]) + + assert store.resolve_client_id(ka["key"]) == a["id"] + assert store.resolve_client_id(kb["key"]) == b["id"] + assert store.resolve_client_id("saas_wrong") is None + assert store.resolve_client_id("") is None + assert store.resolve_client_id(None) is None + + # Revoke A's key only; B's keeps resolving. + ok, _ = store.revoke(a["id"], ka["key_id"]) + assert ok + assert store.resolve_client_id(ka["key"]) is None + assert store.resolve_client_id(kb["key"]) == b["id"] + + +def test_revoke_is_scoped_no_cross_tenant_revoke(tmp_path): + core = BillingCore(tmp_path / "saas.db") + _, a = core.create_client("Alice") + _, b = core.create_client("Bob") + store = ClientKeyStore(tmp_path / "saas.db") + _, ka = store.issue(a["id"]) + + ok, details = store.revoke(b["id"], ka["key_id"]) + assert not ok and (details.get("error") or {}).get("code") == "KEY_NOT_FOUND" + assert store.resolve_client_id(ka["key"]) == a["id"] # still active + + ok, details = store.revoke(a["id"], 999999) + assert not ok and (details.get("error") or {}).get("code") == "KEY_NOT_FOUND" + + +def test_list_and_revoke_all_never_expose_secrets(tmp_path): + core = BillingCore(tmp_path / "saas.db") + _, a = core.create_client("Alice") + store = ClientKeyStore(tmp_path / "saas.db") + _, k1 = store.issue(a["id"]) + _, k2 = store.issue(a["id"]) + ok, listing = store.list_keys(a["id"]) + assert ok and listing["count"] == 2 + for item in listing["keys"]: + assert "key" not in item and "key_hash" not in item + ok, res = store.revoke_all(a["id"]) + assert ok and res["revoked_count"] == 2 + assert store.resolve_client_id(k1["key"]) is None + assert store.resolve_client_id(k2["key"]) is None + + +def test_issue_unknown_client(tmp_path): + BillingCore(tmp_path / "saas.db") + store = ClientKeyStore(tmp_path / "saas.db") + ok, details = store.issue(999) + assert not ok and (details.get("error") or {}).get("code") == "UNKNOWN_CLIENT" + + +# --------------------------------------------------------------------------- +# Operator key management HTTP routes +# --------------------------------------------------------------------------- + +def test_operator_issues_and_revokes_keys(client): + cid = _create_client(client, "KeyCo") + issued = _issue_key(client, cid) + assert issued["client_id"] == cid + assert issued["key"].startswith("saas_") + assert issued["prefix"] == issued["key"][:10] + assert len(issued["key"]) > 20 + + # Listing shows metadata only. + listing = client.get(f"/api/clients/{cid}/keys", headers=AUTH) + assert listing.status_code == 200 + assert listing.get_json()["count"] == 1 + assert "key" not in listing.get_json()["keys"][0] + + # The key works on the client surface. + me = client.get("/api/me", headers=_client_header(issued["key"])) + assert me.status_code == 200 + assert me.get_json()["client_id"] == cid + + # Revoke via the operator route: key stops working. + rev = client.delete(f"/api/clients/{cid}/keys/{issued['key_id']}", + headers=AUTH) + assert rev.status_code == 200 + assert client.get("/api/me", + headers=_client_header(issued["key"])).status_code == 401 + + +def test_key_routes_require_operator_key(client): + cid = _create_client(client, "LockedCo") + assert client.post(f"/api/clients/{cid}/keys").status_code == 401 + assert client.get(f"/api/clients/{cid}/keys").status_code == 401 + # A CLIENT key (other tenant) is 403 on key issuance routes. + key = _issue_key(client, cid) + other = _create_client(client, "OtherCo") + resp = client.post(f"/api/clients/{other}/keys", + headers=_client_header(key["key"])) + assert resp.status_code == 403 + assert resp.get_json() == FORBIDDEN_BODY + + +# --------------------------------------------------------------------------- +# Tenancy: a client key never reaches another client's resources, and never +# reaches an operator route (acceptance criterion 1 + security note) +# --------------------------------------------------------------------------- + +def test_cross_tenant_and_operator_denials(client): + a_id = _create_client(client, "TenantA") + b_id = _create_client(client, "TenantB") + key_a = _issue_key(client, a_id)["key"] + + # Client A's key on client B's resources: 403 everywhere. + for path in ( + f"/api/factory/{b_id}/issues", + f"/api/factory/{b_id}/lock", + f"/api/factory/{b_id}/unlock", + f"/api/clients/{b_id}", + f"/api/clients/{b_id}/ledger", + f"/api/clients/{b_id}/credits", + ): + resp = client.post(path, json={}, headers=_client_header(key_a)) + assert resp.status_code == 403, path + assert resp.get_json() == FORBIDDEN_BODY + + # Operator routes: /api/clients, /api/factory/*, /dashboard. + assert client.get("/api/clients", headers=_client_header(key_a)) \ + .status_code == 403 + assert client.post("/api/factory/provision", + json={"client_id": b_id}, + headers=_client_header(key_a)).status_code == 403 + assert client.get("/dashboard", headers=_client_header(key_a)) \ + .status_code == 403 + assert client.get("/api/dashboard/summary", headers=_client_header(key_a)) \ + .status_code == 403 + + # And the operator key never works on the client surface. + assert client.get("/api/me", headers=AUTH).status_code == 401 + assert client.get("/api/me").get_json() == UNAUTHORIZED_BODY + assert client.post("/api/checkout", json={"amount_minor": 100}, + headers=AUTH).status_code == 401 + + +def test_each_client_sees_only_its_own_me(client): + a_id = _create_client(client, "SelfScopeA") + b_id = _create_client(client, "SelfScopeB") + key_a = _issue_key(client, a_id)["key"] + key_b = _issue_key(client, b_id)["key"] + + me_a = client.get("/api/me", headers=_client_header(key_a)).get_json() + me_b = client.get("/api/me", headers=_client_header(key_b)).get_json() + assert me_a["client_id"] == a_id + assert me_b["client_id"] == b_id + assert me_a["name"] == "SelfScopeA" + assert me_b["name"] == "SelfScopeB" + + +# --------------------------------------------------------------------------- +# Checkout (rate-limited) + webhook credit (idempotent) — acceptance 3 +# --------------------------------------------------------------------------- + +def test_checkout_creates_session_scoped_to_caller(client, tmp_path): + cid = _create_client(client, "CheckoutCo") + key = _issue_key(client, cid)["key"] + resp = client.post("/api/checkout", + json={"amount_minor": 2500, + "success_url": "https://example.test/ok"}, + headers=_client_header(key)) + assert resp.status_code == 201 + body = resp.get_json() + assert set(body) == {"checkout_url", "session_id"} + assert body["checkout_url"].startswith("https://checkout.stripe.com/mock/") + assert body["session_id"].startswith("cs_mock_") + + +def test_checkout_webhook_credit_exactly_once(client, tmp_path): + """Acceptance 3: checkout -> webhook -> balance credit, replay no-op.""" + cid = _create_client(client, "FundCo") + key = _issue_key(client, cid)["key"] + + checkout = client.post("/api/checkout", json={"amount_minor": 2500}, + headers=_client_header(key)) + assert checkout.status_code == 201 + + payload = _webhook_payload(client_id=cid, amount=2500) + headers = {"Stripe-Signature": MOCK_SIG} + first = client.post("/api/webhooks/stripe", + data=json.dumps(payload), # raw bytes path + content_type="application/json", headers=headers) + assert first.status_code == 200 + assert first.get_json()["applied"] is True + + balance = client.get("/api/me", headers=_client_header(key)).get_json() + assert balance["balance_minor"] == 2500 + + # Replayed webhook (same event id): exactly once — no double credit. + replay = client.post("/api/webhooks/stripe", + data=json.dumps(payload), + content_type="application/json", headers=headers) + assert replay.status_code == 200 + balance_after = client.get("/api/me", + headers=_client_header(key)).get_json() + assert balance_after["balance_minor"] == 2500 + + +def test_webhook_rejects_bad_signature_and_bad_payload(client): + cid = _create_client(client, "WebhookCo") + payload = json.dumps(_webhook_payload(client_id=cid)).encode() + resp = client.post("/api/webhooks/stripe", data=payload, + content_type="application/json", + headers={"Stripe-Signature": "wrong-sig"}) + assert resp.status_code == 400 + assert resp.get_json()["error"]["code"] == "invalid_signature" + + # No signature header at all: same generic 400. + resp = client.post("/api/webhooks/stripe", data=payload, + content_type="application/json") + assert resp.status_code == 400 + + +def test_checkout_validation(client): + cid = _create_client(client, "BadCo") + key = _issue_key(client, cid)["key"] + h = _client_header(key) + + assert client.post("/api/checkout", json={"amount_minor": 0}, + headers=h).status_code == 400 + assert client.post("/api/checkout", json={"amount_minor": "50"}, + headers=h).status_code == 400 + assert client.post("/api/checkout", json={"amount_minor": 100, + "currency": "too-long"}, + headers=h).status_code == 400 + assert client.post("/api/checkout", + json={"amount_minor": 100, + "success_url": "javascript:alert(1)"}, + headers=h).status_code == 400 + assert client.post("/api/checkout", json="nope", + headers=h).status_code == 400 + + +def test_checkout_rate_limited_per_client(tmp_path): + """Rate-limit /api/checkout per client (security note).""" + db = tmp_path / "saas.db" + core = BillingCore(db) + ok, rec = core.create_client("RatedCo") + store = ClientKeyStore(db) + ok, issued = store.issue(rec["id"]) + + import flask + from gitea_saas.client_api import ClientStatus + app = flask.Flask(__name__) + app.config["API_KEY"] = API_KEY + surface = ClientSurface(core, store, ClientStatus(core, db), + _mock_stripe_builder, + checkout_rate=2, checkout_window_s=60) + register_client_routes(app, surface, + gitea=FakeGitea(), + config=type("C", (), {"db_path": db})()) + app.config.update(TESTING=True) + c = app.test_client() + h = _client_header(issued["key"]) + + assert c.post("/api/checkout", json={"amount_minor": 100}, + headers=h).status_code == 201 + assert c.post("/api/checkout", json={"amount_minor": 100}, + headers=h).status_code == 201 + third = c.post("/api/checkout", json={"amount_minor": 100}, headers=h) + assert third.status_code == 429 + assert third.get_json()["error"]["code"] == "rate_limited" + assert third.headers.get("Retry-After") is not None + + # Other client's limiter is its own (no cross-tenant throttling). + ok, rec2 = core.create_client("OtherRated") + ok, issued2 = store.issue(rec2["id"]) + assert c.post("/api/checkout", json={"amount_minor": 100}, + headers=_client_header(issued2["key"])).status_code == 201 + + +def test_stripe_unconfigured_fails_closed(tmp_path, monkeypatch): + """No Stripe mode/creds -> checkout & webhook 503, never a 200.""" + monkeypatch.delenv("SAAS_STRIPE_MODE", raising=False) + monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False) + monkeypatch.delenv("STRIPE_WEBHOOK_SECRET", raising=False) + + from gitea_saas.app import create_app + application = create_app(_cfg(tmp_path, API_KEY), gitea=FakeGitea()) + application.config.update(TESTING=True) + c = application.test_client() + cid = c.post("/api/clients", json={"name": "NoStripe"}, + headers=AUTH).get_json()["id"] + key = c.post(f"/api/clients/{cid}/keys", headers=AUTH).get_json()["key"] + + checkout = c.post("/api/checkout", json={"amount_minor": 100}, + headers=_client_header(key)) + assert checkout.status_code == 503 + assert checkout.get_json()["error"]["code"] == "stripe_unconfigured" + + webhook = c.post("/api/webhooks/stripe", + data=json.dumps(_webhook_payload()).encode(), + content_type="application/json", + headers={"Stripe-Signature": "anything"}) + assert webhook.status_code == 503 + + +# --------------------------------------------------------------------------- +# /api/me status (balance, queued, in-flight, delivered) +# --------------------------------------------------------------------------- + +def test_me_reports_balance_and_counts(client, gitea, tmp_path): + cid = _create_client(client, "StatusCo") + key = _issue_key(client, cid)["key"] + + # Unfunded submit -> queued (no Gitea issue created). + resp = client.post("/api/me/issues", + json={"repo": "statusco-repo", + "title": "First task", + "acceptance_body": "do it"}, + headers=_client_header(key)) + assert resp.status_code == 202 + assert resp.get_json()["delivered"] is False + me = client.get("/api/me", headers=_client_header(key)).get_json() + assert me["queued_items"] == 1 + assert me["in_flight_items"] == 0 + assert me["delivered_count"] == 0 + assert me["balance_minor"] == 0 + + # Fund + lock: submits now queue; unlock flushes them to Gitea, which + # is what the factory records as "shipped" (in-flight, awaiting merge). + core = BillingCore(tmp_path / "saas.db") + ok, _ = core.credit(cid, 10000, "fund-1") + assert ok + assert client.post(f"/api/factory/{cid}/lock", json={}, + headers=AUTH).status_code == 200 + resp = client.post("/api/me/issues", + json={"repo": "statusco-repo", + "title": "Second task", + "acceptance_body": "do it too"}, + headers=_client_header(key)) + assert resp.status_code == 202 + assert resp.get_json()["reason"] == "locked" + me = client.get("/api/me", headers=_client_header(key)).get_json() + assert me["queued_items"] == 2 # unfunded + locked + assert me["in_flight_items"] == 0 + + flush = client.post(f"/api/factory/{cid}/unlock", json={}, headers=AUTH) + assert flush.status_code == 200 + assert flush.get_json()["flushed"] == 2 + assert len(gitea.issues) == 2 + me = client.get("/api/me", headers=_client_header(key)).get_json() + assert me["queued_items"] == 0 + assert me["in_flight_items"] == 2 # shipped to Gitea, not delivered + assert me["balance_minor"] == 10000 + + # Delivery debit (what delivery.py writes on merge) -> delivered count. + ok, _ = core.debit(cid, 500, "deliver:statusco-repo:1:sha", + ref_type="pr", ref_id="statusco-repo#1") + assert ok + me = client.get("/api/me", headers=_client_header(key)).get_json() + assert me["delivered_count"] == 1 + assert me["balance_minor"] == 9500 + + +def test_me_issues_validation_and_scoping(client, tmp_path): + cid = _create_client(client, "SubmitCo") + other = _create_client(client, "OtherSubmit") + key = _issue_key(client, cid)["key"] + other_key = _issue_key(client, other)["key"] + h = _client_header(key) + + assert client.post("/api/me/issues", json={"repo": "../evil", + "title": "x"}, + headers=h).status_code == 400 + assert client.post("/api/me/issues", json={"repo": "good-repo", + "title": ""}, + headers=h).status_code == 400 + + # The submit is ALWAYS scoped to the caller — a queued row can only + # ever belong to the key's own client_id (factory enforces existence). + resp = client.post("/api/me/issues", + json={"repo": "submitco-repo", "title": "Task"}, + headers=_client_header(other_key)) + assert resp.status_code == 202 + me_other = client.get("/api/me", headers=_client_header(other_key)).get_json() + assert me_other["queued_items"] == 1 + me_self = client.get("/api/me", headers=h).get_json() + assert me_self["queued_items"] == 0 + + +# --------------------------------------------------------------------------- +# Assembled-app reachability + fail-closed 503 (no SAAS_API_KEY) +# --------------------------------------------------------------------------- + +def test_assembled_app_has_client_routes(client_factory, tmp_path, monkeypatch): + monkeypatch.delenv("SAAS_API_KEY", raising=False) + from gitea_saas.app import create_app + application = create_app(_cfg(tmp_path, api_key=None), gitea=FakeGitea()) + application.config.update(TESTING=True) + c = application.test_client() + # No SAAS_API_KEY: every authed route (client surface included) is 503. + assert c.get("/api/me").status_code == 503 + assert c.post("/api/checkout", json={"amount_minor": 100}).status_code == 503 + assert c.post("/api/me/issues", json={}).status_code == 503