Verified catalogue of mobiles and laptops sold in India, collected from real retail listings (FastAPI backend, React frontend, Postgres/pgvector). - REST API under /api/elec (read-only catalogue; admin endpoints need login) - MCP server (FastMCP) at /mcp/ with list_categories, search_products, get_product and price_history tools - Real ratings and reviews read from product pages and search results - Production Dockerfile (requirements-api.txt, no PyTorch) and .env.production.example; remote database only via an explicit ELEC_ALLOW_REMOTE_DB host/name allowlist - docs/API.md: endpoint and MCP reference with live examples Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
145 lines
4.9 KiB
Python
145 lines
4.9 KiB
Python
"""
|
|
Request-scoped authentication dependencies.
|
|
|
|
Guards are attached per route, not as middleware matching on paths. Two
|
|
reasons that matters here:
|
|
|
|
* A path-matching middleware silently stops guarding a route the moment
|
|
somebody renames it. A ``Depends`` on the route function cannot drift out
|
|
of sync with the route it protects.
|
|
* FastAPI reflects these into the OpenAPI schema, so ``/docs`` shows which
|
|
operations need a credential instead of implying everything is open.
|
|
|
|
The guard therefore holds regardless of which host the request arrives on -
|
|
through the frontend's nginx on ``{$DOMAIN}``, or directly on ``api.{$DOMAIN}``.
|
|
|
|
Usage::
|
|
|
|
@router.post("/thing", dependencies=[Depends(require_admin)])
|
|
def create_thing(): ...
|
|
|
|
@router.post("/other", dependencies=[Depends(require_permission("add_product"))])
|
|
def other_thing(): ...
|
|
|
|
@router.post("/who", ...)
|
|
def who(principal: Principal = Depends(get_principal)): ...
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Callable, Optional
|
|
|
|
from fastapi import Depends, HTTPException, status
|
|
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
|
|
|
|
from app.infrastructure.security import (
|
|
AuthError,
|
|
Principal,
|
|
anonymous_principal,
|
|
decode_access_token,
|
|
principal_for_api_key,
|
|
)
|
|
from app.infrastructure.settings import AUTH_ENABLED
|
|
|
|
# auto_error=False on both: with two accepted credential types, letting either
|
|
# scheme raise on its own would reject a request that carried the *other* one.
|
|
# get_principal decides, once it has seen both.
|
|
_bearer_scheme = HTTPBearer(auto_error=False, description="Access token from POST /api/auth/login")
|
|
_api_key_scheme = APIKeyHeader(
|
|
name="X-API-Key",
|
|
auto_error=False,
|
|
description="Static key for machine consumers (see API_KEYS)",
|
|
)
|
|
|
|
_UNAUTHENTICATED = HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Not authenticated. Send a bearer token from POST /api/auth/login, or an X-API-Key header.",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
|
|
def get_principal(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme),
|
|
api_key: Optional[str] = Depends(_api_key_scheme),
|
|
) -> Principal:
|
|
"""Resolve the caller, or raise 401. Use this to require *any* valid credential."""
|
|
if not AUTH_ENABLED:
|
|
return anonymous_principal()
|
|
|
|
if credentials is not None and credentials.credentials:
|
|
try:
|
|
return decode_access_token(credentials.credentials)
|
|
except AuthError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail=str(exc),
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
) from exc
|
|
|
|
if api_key:
|
|
try:
|
|
return principal_for_api_key(api_key)
|
|
except AuthError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)
|
|
) from exc
|
|
|
|
raise _UNAUTHENTICATED
|
|
|
|
|
|
def get_optional_principal(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme),
|
|
api_key: Optional[str] = Depends(_api_key_scheme),
|
|
) -> Optional[Principal]:
|
|
"""
|
|
Resolve the caller if they presented a valid credential, else None.
|
|
|
|
For endpoints that are public but behave differently when signed in. A
|
|
credential that is present but *invalid* still raises - failing open there
|
|
would mean a typo'd token silently downgrades to anonymous access.
|
|
"""
|
|
if not AUTH_ENABLED:
|
|
return anonymous_principal()
|
|
if credentials is None and not api_key:
|
|
return None
|
|
return get_principal(credentials, api_key)
|
|
|
|
|
|
def require_role(*roles: str) -> Callable[[Principal], Principal]:
|
|
"""Require the caller to hold one of ``roles``."""
|
|
allowed = frozenset(roles)
|
|
|
|
def _dependency(principal: Principal = Depends(get_principal)) -> Principal:
|
|
if principal.role not in allowed:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=(
|
|
f"This operation requires the {' or '.join(sorted(allowed))} role; "
|
|
f"you are signed in as '{principal.role}'."
|
|
),
|
|
)
|
|
return principal
|
|
|
|
return _dependency
|
|
|
|
|
|
def require_permission(permission: str) -> Callable[[Principal], Principal]:
|
|
"""
|
|
Require a specific permission. ``admin`` passes every check - see
|
|
``Principal.has_permission``.
|
|
"""
|
|
|
|
def _dependency(principal: Principal = Depends(get_principal)) -> Principal:
|
|
if not principal.has_permission(permission):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=f"This operation requires the '{permission}' permission.",
|
|
)
|
|
return principal
|
|
|
|
return _dependency
|
|
|
|
|
|
# The two guards used most often, named so route decorators stay readable.
|
|
require_admin = require_role("admin")
|
|
require_authenticated = get_principal
|