pagepiper/app/cloud_session.py
pyr0ball 8eef52a054 feat: per-user database isolation for cloud instances (closes #4)
Implements Option A from the issue design: each cloud user gets their own
data directory (DATA_DIR/users/{user_id}/) with separate pagepiper.db,
pagepiper_vecs.db, uploads/, and books/. Local mode is unchanged.

Key changes:
- app/startup.py: extract apply_migrations, reembed_docs,
  check_and_rebuild_vec_schema out of main.py (no circular imports)
- app/config.py: add LOCAL_USER_ID constant and user_data_dir() helper
- app/cloud_session.py: extract resolve_authenticated_user(); require_paid_tier
  now returns user_id (str) instead of None
- app/deps.py: add UserCtx dataclass (db_path, vec_db_path, data_dir,
  watch_dir, bm25) + get_user_ctx dependency; per-user startup guard runs
  migrations + vec schema check once per process per user
- app/main.py: _bm25 singleton -> _bm25_map dict keyed by user_id;
  add _get_bm25_for(); lifespan only runs startup checks in local mode
- app/api/library.py, search.py, chat.py: thread UserCtx through all
  endpoints; remove module-level _mark_bm25_dirty injection pattern
- tests/conftest.py: override get_user_ctx in addition to get_db so all
  endpoints get a consistent test UserCtx
2026-05-13 16:31:51 -07:00

131 lines
4.2 KiB
Python

# app/cloud_session.py
"""Cloud session auth for Pagepiper — validates cf_session cookie via Directus + Heimdall.
In local mode (CLOUD_MODE unset or false), require_paid_tier is a no-op.
In cloud mode, the Caddy proxy forwards the browser's Cookie header as
X-CF-Session. This module extracts cf_session, validates it against
Directus /users/me, then checks the user's Pagepiper tier via Heimdall.
Auto-provisions a free tier key for new users.
"""
from __future__ import annotations
import logging
import os
import re
import httpx
from fastapi import HTTPException, Request
log = logging.getLogger(__name__)
CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes")
DIRECTUS_URL: str = os.environ.get("DIRECTUS_URL", "http://172.31.0.3:8055").rstrip("/")
HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech").rstrip("/")
HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "")
_TIER_ORDER = {"free": 0, "paid": 1, "premium": 2, "ultra": 3}
def _extract_session_token(cookie_header: str) -> str:
m = re.search(r"(?:^|;)\s*cf_session=([^;]+)", cookie_header)
return m.group(1).strip() if m else ""
def _get_user_id(jwt: str) -> str | None:
try:
resp = httpx.get(
f"{DIRECTUS_URL}/users/me",
headers={"Authorization": f"Bearer {jwt}"},
timeout=5.0,
)
if resp.status_code == 200:
return resp.json().get("data", {}).get("id")
except Exception as exc:
log.warning("Directus session check failed: %s", exc)
return None
def _ensure_provisioned(user_id: str) -> None:
if not HEIMDALL_ADMIN_TOKEN:
return
try:
httpx.post(
f"{HEIMDALL_URL}/admin/provision",
json={"directus_user_id": user_id, "product": "pagepiper", "tier": "free"},
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
timeout=5.0,
)
except Exception as exc:
log.warning("Heimdall provision failed for user %s: %s", user_id, exc)
def _get_tier(user_id: str) -> str:
if not HEIMDALL_ADMIN_TOKEN:
return "free"
try:
resp = httpx.get(
f"{HEIMDALL_URL}/admin/cloud/resolve",
params={"directus_user_id": user_id, "product": "pagepiper"},
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
timeout=5.0,
)
if resp.status_code == 200:
return resp.json().get("tier", "free")
except Exception as exc:
log.warning("Heimdall tier check failed for user %s: %s", user_id, exc)
return "free"
def resolve_authenticated_user(request: Request) -> str:
"""Validate the session cookie and return the Directus user_id. Raises 401 if invalid."""
cookie_header = request.headers.get("x-cf-session", "")
jwt = _extract_session_token(cookie_header)
if not jwt:
raise HTTPException(
status_code=401,
detail={
"error": "auth_required",
"message": "Sign in at circuitforge.tech to use Pagepiper cloud.",
},
)
user_id = _get_user_id(jwt)
if not user_id:
raise HTTPException(
status_code=401,
detail={
"error": "session_invalid",
"message": "Your session has expired. Sign in again at circuitforge.tech.",
},
)
_ensure_provisioned(user_id)
return user_id
def require_paid_tier(request: Request) -> str:
"""FastAPI dependency — 401 if no valid session, 402 if tier < paid. Returns user_id.
In local mode (CLOUD_MODE not set), returns LOCAL_USER_ID without any auth check.
"""
if not CLOUD_MODE:
from app.config import LOCAL_USER_ID
return LOCAL_USER_ID
user_id = resolve_authenticated_user(request)
tier = _get_tier(user_id)
if _TIER_ORDER.get(tier, 0) < _TIER_ORDER["paid"]:
raise HTTPException(
status_code=402,
detail={
"error": "upgrade_required",
"message": (
"RAG chat requires a Paid tier Pagepiper license. "
"Upgrade at circuitforge.tech/software/pagepiper."
),
},
)
return user_id