"""FastAPI auth dependencies — current user resolution. Authorization helpers (require_admin, require_resource_access) live in ``app.auth.access`` to avoid a circular import — they need ``get_current_user`` from this module and ``_get_db``, which both come from here. """ import json import logging import os from typing import Optional import duckdb from fastapi import Depends, HTTPException, Header, Request, status from app.auth.jwt import verify_token from src.db import get_system_db from src.repositories.users import UserRepository logger = logging.getLogger(__name__) # Default dev user used when LOCAL_DEV_MODE=1. Seeded at startup by app/main.py. LOCAL_DEV_DEFAULT_EMAIL = "dev@localhost" # Single-slot cache for the parsed LOCAL_DEV_GROUPS value, keyed by the raw env # string. Avoids re-parsing JSON on every authenticated request without the # surprise of test isolation issues — when the env changes (typical in tests), # the key changes and the cache transparently re-parses. _LOCAL_DEV_GROUPS_CACHE: tuple[str, list[dict]] | None = None # Map pat_resolver.ResolutionReason → HTTP 401 `detail` string. Preserves the # specific user-facing messages that existed before the pat_resolver refactor # (Account deactivated, Token revoked, ...) so tests and admin UX that grep # for these phrases keep working. _AUTH_DETAIL_BY_REASON = { "deactivated": "Account deactivated", "user_not_found": "User not found", "pat_unknown": "Token unknown", "pat_revoked": "Token revoked", "pat_expired": "Token expired", "pat_mismatch": "Token mismatch", "invalid_token": "Invalid or expired token", "no_token": "Invalid or expired token", } def is_local_dev_mode() -> bool: """True when LOCAL_DEV_MODE=1 — unsafe for production, bypasses auth.""" return os.environ.get("LOCAL_DEV_MODE", "").lower() in ("1", "true", "yes") def get_local_dev_email() -> str: """Email of the auto-logged-in dev user. Configurable via LOCAL_DEV_USER_EMAIL.""" return os.environ.get("LOCAL_DEV_USER_EMAIL", LOCAL_DEV_DEFAULT_EMAIL) def get_local_dev_groups() -> list[dict]: """Mock Google Workspace groups for the dev user when LOCAL_DEV_MODE is on. Reads ``LOCAL_DEV_GROUPS`` as a JSON array of objects matching the shape produced by ``_fetch_google_groups`` — ``[{"id": "...", "name": "..."}]``. Items must have a non-empty ``id``; ``name`` defaults to ``id`` when omitted. Extra fields are preserved verbatim so future group attributes (roles, labels, …) can be mocked without touching this parser. Returns ``[]`` on missing/empty/malformed input — dev mock must never break the dev flow. Malformed input is logged at WARNING. Cached single-slot: re-parses only when the raw env-var value changes. """ global _LOCAL_DEV_GROUPS_CACHE raw = os.environ.get("LOCAL_DEV_GROUPS", "").strip() if _LOCAL_DEV_GROUPS_CACHE is not None and _LOCAL_DEV_GROUPS_CACHE[0] == raw: return _LOCAL_DEV_GROUPS_CACHE[1] result = _parse_local_dev_groups(raw) _LOCAL_DEV_GROUPS_CACHE = (raw, result) return result def _parse_local_dev_groups(raw: str) -> list[dict]: if not raw: return [] try: parsed = json.loads(raw) except json.JSONDecodeError as e: logger.warning("LOCAL_DEV_GROUPS is not valid JSON, ignoring: %s", e) return [] if not isinstance(parsed, list): logger.warning( "LOCAL_DEV_GROUPS must be a JSON array, got %s — ignoring", type(parsed).__name__, ) return [] out: list[dict] = [] for item in parsed: if not isinstance(item, dict) or not item.get("id"): logger.warning( "LOCAL_DEV_GROUPS item must be an object with 'id', skipping: %r", item, ) continue # Don't mutate the parsed input — keeps the parser pure so the cache # value stays a fresh list on each rebuild. out.append({**item, "name": item.get("name") or item["id"]}) return out def _get_db(): conn = get_system_db() try: yield conn finally: conn.close() def _client_ip(request: Optional[Request]) -> Optional[str]: """Return the request's client IP, preferring the first hop of X-Forwarded-For. Trust model: this deployment runs behind Caddy (see repo Caddyfile), which strips incoming X-Forwarded-For and sets its own. The leftmost hop is therefore trustworthy. If the app is ever exposed directly to the internet without a proxy, this value becomes client-settable and should only be relied on for audit/diagnostics, never access control. Value is stored in personal_access_tokens.last_used_ip and audit_log entries — informational only, never authorization. """ if request is None: return None xff = request.headers.get("x-forwarded-for") if xff: return xff.split(",", 1)[0].strip() or None client = getattr(request, "client", None) return getattr(client, "host", None) if client else None def _get_local_dev_user(conn: duckdb.DuckDBPyConnection) -> Optional[dict]: """Return the seeded dev user when LOCAL_DEV_MODE is on, else None.""" repo = UserRepository(conn) user = repo.get_by_email(get_local_dev_email()) if not user: logger.error( "LOCAL_DEV_MODE is on but dev user %s is not seeded; expected app startup to seed it", get_local_dev_email(), ) return user def _stash_user(request: Optional[Request], user: dict) -> dict: """Park the resolved user on ``request.state.user``. Read by response-phase middleware (e.g. the PostHog snippet injector and the 500 handler) so they can identify the actor without re-running the auth dependency. Tolerant of ``None`` requests (background paths that call this helper from non-HTTP contexts). """ if request is not None: try: request.state.user = user except Exception: pass return user async def get_current_user( request: Request = None, authorization: Optional[str] = Header(None), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ) -> dict: """Extract and validate JWT from Authorization header or cookie. Returns user dict. No role hydration, no session caches — authorization is decided at gate time by ``app.auth.access`` which reads ``user_group_members`` directly. """ if is_local_dev_mode(): user = _get_local_dev_user(conn) if user: _attach_admin_flag(user, conn) return _stash_user(request, user) # Fall through to normal auth if seed missing — surfaces the bug # instead of hiding it. token = None # Try Authorization header first if authorization and authorization.startswith("Bearer "): token = authorization.removeprefix("Bearer ") # Fallback to cookie (for web UI after OAuth redirect) if not token and request: token = request.cookies.get("access_token") if not token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing or invalid Authorization header", ) # Shared-secret path for the in-cluster scheduler. Checked before # pat_resolver because the scheduler token is not a JWT — feeding it to # verify_token() would log a spurious decode warning every cron tick. # See app/auth/scheduler_token.py for the threat model. from app.auth.scheduler_token import get_scheduler_user, is_scheduler_token if is_scheduler_token(token): scheduler_user = get_scheduler_user(conn) if scheduler_user: _attach_admin_flag(scheduler_user, conn) return _stash_user(request, scheduler_user) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Scheduler user not provisioned", ) from app.auth.pat_resolver import resolve_token_to_user user, reason = resolve_token_to_user(conn, token, request) if user: _attach_admin_flag(user, conn) # Propagate token kind so audit helpers can tag client_kind correctly. payload = verify_token(token) or {} if payload.get("typ") == "pat": user["token_type"] = "pat" return _stash_user(request, user) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=_AUTH_DETAIL_BY_REASON.get(reason, "Invalid or expired token"), ) def _attach_admin_flag(user: dict, conn: duckdb.DuckDBPyConnection) -> None: """Inject ``user["is_admin"]`` so templates and route handlers can gate admin-only UI without touching the legacy ``users.role`` column. v13 nulled out ``users.role`` and moved admin authority onto ``user_group_members`` (Admin system group). The web header used to gate its admin nav on ``session.user.role == 'admin'``, which silently became false for every user — so no admin saw any admin menu items after the v13 migration. Computing the flag once per request here keeps every consumer in sync with ``app.auth.access.is_user_admin`` (the same call all server-side admin gates use). """ from app.auth.access import is_user_admin user_id = user.get("id") if user_id: try: user["is_admin"] = is_user_admin(user_id, conn) except Exception: user["is_admin"] = False else: user["is_admin"] = False async def get_optional_user( request: Request = None, authorization: Optional[str] = Header(None), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ) -> Optional[dict]: """Like get_current_user but returns None instead of 401 if no token.""" try: return await get_current_user(request=request, authorization=authorization, conn=conn) except HTTPException: return None async def require_session_token(request: Request, user: dict = Depends(get_current_user)) -> dict: """Like get_current_user but rejects every non-interactive token kind — for endpoints that must not be callable via a long-lived service or CI credential (e.g. creating new tokens, changing password). Two non-interactive paths exist today: 1. **PAT** — JWT with ``typ="pat"``. Detected by decoding the JWT and inspecting the claim. 2. **Scheduler shared secret** — opaque string equal to ``SCHEDULER_API_TOKEN``. Not a JWT, so ``verify_token`` returns None and the PAT-claim check would silently pass — meaning a caller holding the scheduler secret could mint persistent PATs through ``POST /auth/tokens`` that survive a secret rotation. Explicit check here closes that bypass. """ auth = request.headers.get("authorization", "") token = None if auth.startswith("Bearer "): token = auth.removeprefix("Bearer ") if not token and request: token = request.cookies.get("access_token") if token: from app.auth.scheduler_token import is_scheduler_token if is_scheduler_token(token): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="This endpoint requires an interactive session, not a service token", ) from app.auth.jwt import verify_token payload = verify_token(token) or {} if payload.get("typ") == "pat": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="This endpoint requires an interactive session, not a PAT", ) return user