feat(server): mint API + Postgres schema + manual adapter (PR 1)
Source-agnostic license issuance service. FastAPI app fronts a Postgres `licenses` table; the only currently-wired source is `manual` (operator mints via /internal/mint). Gumroad webhook adapter lands in PR 2. Key design points: - Signing reuses src/license/crypto.py via a COPY into the image (single source of truth — blobs minted server-side verify against the same embedded pubkey on the buyer's machine). - Source adapter Protocol (app/adapters/base.py) is the seam for Gumroad / Lemon Squeezy / Stripe in later PRs; Mint API speaks only SaleEvent / RefundEvent. - (source, source_order_id) UNIQUE composite gives idempotent webhook retries without double-mint. - JSONB type uses with_variant(JSON, 'sqlite') so the same models drive both Postgres prod and SQLite tests (no testcontainers dep). - Bearer-token auth on /internal/*; the IP-loopback guard was removed after the docker bridge made it fight legitimate prod traffic (nginx defense + Bearer remain). - Secrets resolved via *_FILE env vars pointing at /run/secrets/<name>, so passwords never appear in `docker inspect`. 21 unit tests (SQLite in-memory, StaticPool) plus a real-Postgres docker-compose smoke test in server/scripts/smoke.sh that builds the image, runs the alembic migration, mints a license, verifies the signature against the host dev pubkey, and checks the DB row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
65
server/app/auth.py
Normal file
65
server/app/auth.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Auth guards for ``/internal/*``.
|
||||
|
||||
Active layer: Bearer token, presented by the operator's CLI and
|
||||
matched against the value in the secrets dir. Token rotation =
|
||||
update the file, restart the container.
|
||||
|
||||
:func:`require_localhost` is preserved but unused by default — it
|
||||
fights the Docker bridge network model (the container sees the
|
||||
gateway IP, not 127.0.0.1, regardless of where traffic originated).
|
||||
Re-enable it only if the API runs in ``network_mode: host``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def require_localhost(request: Request) -> None:
|
||||
"""Reject the request unless the connecting peer is loopback.
|
||||
|
||||
``request.client.host`` reflects the actual TCP peer (the nginx
|
||||
upstream connecting from 127.0.0.1) when ``proxy_set_header`` is
|
||||
used appropriately. We deliberately do NOT trust
|
||||
``X-Forwarded-For`` here — we want the raw peer.
|
||||
"""
|
||||
peer = request.client.host if request.client else None
|
||||
if peer not in {"127.0.0.1", "::1"}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Not found",
|
||||
)
|
||||
|
||||
|
||||
def require_bearer_token(request: Request) -> None:
|
||||
"""Verify ``Authorization: Bearer <admin_token>``.
|
||||
|
||||
Uses constant-time comparison so timing leaks don't reveal token
|
||||
prefixes. The 401 deliberately doesn't echo the supplied token or
|
||||
leak whether a token is configured at all — clients should treat
|
||||
"no token configured" the same as "wrong token".
|
||||
"""
|
||||
settings = get_settings()
|
||||
expected: Optional[str] = settings.resolve_admin_token()
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Server not configured for internal access.",
|
||||
)
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Bearer token required.",
|
||||
)
|
||||
presented = auth.removeprefix("Bearer ").strip()
|
||||
if not hmac.compare_digest(presented, expected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token.",
|
||||
)
|
||||
Reference in New Issue
Block a user