Spaces:
Running
Running
| """Authentication / RBAC layer for the FastAPI app. | |
| Uses Supabase Auth (GoTrue) — the frontend signs in with Google and sends the | |
| resulting access token as ``Authorization: Bearer <jwt>``. We verify the token | |
| by calling GoTrue's ``/auth/v1/user`` endpoint (no extra JWT dependency), then | |
| resolve the local ``profiles`` row (role / is_active) via the service-role store. | |
| All endpoints are protected through FastAPI dependencies: | |
| * ``get_current_user`` — 401 if no/invalid token, 403 if the account is disabled. | |
| * ``get_optional_user`` — returns ``None`` when no token is present (anonymous). | |
| * ``require_admin`` — 403 unless the resolved profile has role 'admin'. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| import httpx | |
| from fastapi import Depends, HTTPException, Request | |
| from backend.config import settings | |
| from backend.storage import jobs as job_store | |
| class CurrentUser: | |
| profile_id: str | |
| auth_user_id: str | |
| email: str | |
| role: str | |
| is_active: bool | |
| monthly_review_limit: int | None = None | |
| monthly_cost_limit_usd: float | None = None | |
| def is_admin(self) -> bool: | |
| return self.role == "admin" | |
| def _admin_email_set() -> set[str]: | |
| return {e.strip().lower() for e in (settings.admin_emails or "").split(",") if e.strip()} | |
| def _bearer_token(request: Request) -> str | None: | |
| header = request.headers.get("Authorization") or request.headers.get("authorization") | |
| if not header: | |
| return None | |
| parts = header.split(" ", 1) | |
| if len(parts) == 2 and parts[0].lower() == "bearer" and parts[1].strip(): | |
| return parts[1].strip() | |
| return None | |
| async def _verify_token(token: str) -> dict: | |
| """Validate the Supabase access token via GoTrue and return the user object.""" | |
| if not settings.supabase_url: | |
| raise HTTPException(status_code=500, detail="Supabase is not configured.") | |
| headers = {"Authorization": f"Bearer {token}"} | |
| if settings.supabase_anon_key: | |
| headers["apikey"] = settings.supabase_anon_key | |
| url = settings.supabase_url.rstrip("/") + "/auth/v1/user" | |
| try: | |
| async with httpx.AsyncClient(timeout=10.0) as client: | |
| resp = await client.get(url, headers=headers) | |
| except httpx.HTTPError: | |
| raise HTTPException(status_code=503, detail="Auth service unavailable.") | |
| if resp.status_code != 200: | |
| raise HTTPException(status_code=401, detail="Invalid or expired session.") | |
| return resp.json() | |
| async def _resolve_current_user(token: str) -> CurrentUser: | |
| user = await _verify_token(token) | |
| auth_user_id = user.get("id") | |
| email = (user.get("email") or "").strip() | |
| if not auth_user_id or not email: | |
| raise HTTPException(status_code=401, detail="Token has no user identity.") | |
| meta = user.get("user_metadata") or {} | |
| # Only trust the email for admin / welcome decisions if the provider verified | |
| # it (Google always does). Prevents claiming an unverified admin email. | |
| email_verified = bool( | |
| user.get("email_confirmed_at") | |
| or user.get("confirmed_at") | |
| or meta.get("email_verified") | |
| ) | |
| profile = await job_store.ensure_profile_for_auth_user( | |
| auth_user_id, | |
| email, | |
| display_name=meta.get("full_name") or meta.get("name"), | |
| avatar_url=meta.get("avatar_url"), | |
| ) | |
| role = profile.get("role") or "user" | |
| pmeta = profile.get("metadata") or {} | |
| # Auto-promote configured admin emails (bootstraps the first admin before any | |
| # SQL seed). Gated so it cannot override a deliberate demote (role_locked, set | |
| # whenever an admin edits a role) and cannot be triggered by an unverified email. | |
| if ( | |
| email_verified | |
| and role != "admin" | |
| and not pmeta.get("role_locked") | |
| and email.lower() in _admin_email_set() | |
| ): | |
| updated = await job_store.set_profile_role(profile["id"], "admin") | |
| role = (updated or {}).get("role", "admin") | |
| is_active = profile.get("is_active", True) | |
| if is_active is False: | |
| raise HTTPException(status_code=403, detail="Account is disabled.") | |
| return CurrentUser( | |
| profile_id=profile["id"], | |
| auth_user_id=auth_user_id, | |
| email=email, | |
| role=role, | |
| is_active=bool(is_active), | |
| monthly_review_limit=profile.get("monthly_review_limit"), | |
| monthly_cost_limit_usd=profile.get("monthly_cost_limit_usd"), | |
| ) | |
| async def get_current_user(request: Request) -> CurrentUser: | |
| token = _bearer_token(request) | |
| if not token: | |
| raise HTTPException(status_code=401, detail="Authentication required.") | |
| return await _resolve_current_user(token) | |
| async def get_optional_user(request: Request) -> CurrentUser | None: | |
| token = _bearer_token(request) | |
| if not token: | |
| return None | |
| try: | |
| return await _resolve_current_user(token) | |
| except HTTPException as exc: | |
| # The anonymous path must keep working: a stale/expired/invalid token, a | |
| # disabled account, or a transient auth-service outage should degrade to | |
| # anonymous rather than hard-failing submit. (auth_required_for_submit | |
| # still rejects None upstream, so this does not weaken a required-login.) | |
| if exc.status_code in (401, 403, 503): | |
| return None | |
| raise | |
| async def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: | |
| if not user.is_admin: | |
| raise HTTPException(status_code=403, detail="Admin access required.") | |
| return user | |