Updated backend

This commit is contained in:
sriram
2026-08-12 16:37:28 +05:30
parent eb3567df62
commit ac8cfacbc9
21 changed files with 1496 additions and 155 deletions

144
app/api/deps.py Normal file
View File

@@ -0,0 +1,144 @@
"""
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

View File

@@ -6,8 +6,9 @@ import logging
from typing import Any, Dict, List, Optional
import pandas as pd
from pydantic import BaseModel, Field
from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from app.api.deps import require_permission
from app.infrastructure.settings import S3_BUCKET
from app.services.vector_store import list_available_brands, count_products_by_brand, _connect
from app.services.s3_service import s3_service
@@ -55,7 +56,7 @@ def get_project_details() -> dict:
}
@router.post("/upload-dataset")
@router.post("/upload-dataset", dependencies=[Depends(require_permission("upload_train_test"))])
async def upload_training_dataset(file: UploadFile = File(...)) -> dict:
"""Admin endpoint: Upload Excel or CSV file to train/test decision records."""
if not file.filename:
@@ -94,7 +95,7 @@ async def upload_training_dataset(file: UploadFile = File(...)) -> dict:
}
@router.post("/allocate-discounts")
@router.post("/allocate-discounts", dependencies=[Depends(require_permission("allocate_discounts"))])
def allocate_discounts_by_stock(payload: BulkDiscountAllocationRequest) -> dict:
"""Admin endpoint: Dynamically allocate discounts on products based on remaining stock levels.
Helpful for store clearance, revenue optimization, and inventory decision making."""

View File

@@ -1,120 +1,259 @@
"""Authentication router for role-based access control (Admin, User, Store)."""
"""
Authentication router - issues and inspects access tokens.
This replaces an earlier version that returned a role profile without issuing
anything, accepted an empty password, and granted `admin` to any username that
asked for the role. It decided which buttons the UI drew; it protected nothing.
Now the token this returns is the credential every write endpoint checks (see
app/api/deps.py), so the rules hold for curl and partner scripts too, not just
for the React app.
Accounts come from the environment - two of them, admin and user, configured as
PBKDF2 digests. That is deliberately not a user database: this project has no
user table, no registration flow and no password reset, and inventing one here
would be a bigger change than the problem calls for. Machine consumers get
API_KEYS instead. If per-user accounts become a real requirement, this module
is the seam to replace.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
import threading
import time
from typing import Dict, List, Tuple
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from fastapi import APIRouter, HTTPException, status
from app.api.deps import get_principal
from app.infrastructure.security import (
ROLE_PERMISSIONS,
Principal,
create_access_token,
verify_password,
)
from app.infrastructure.settings import (
AUTH_ADMIN_PASSWORD_HASH,
AUTH_ADMIN_USERNAME,
AUTH_ENABLED,
AUTH_LOCKOUT_SECONDS,
AUTH_MAX_LOGIN_ATTEMPTS,
AUTH_USER_PASSWORD_HASH,
AUTH_USER_USERNAME,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
role: Optional[str] = None # Optional override if using role selector
username: str = Field(min_length=1, max_length=150)
password: str = Field(min_length=1, max_length=1024)
class UserProfile(BaseModel):
username: str
role: str # 'admin', 'user', or 'store'
role: str
display_name: str
email: str
permissions: List[str] = Field(default_factory=list)
# Predefined user credentials for system roles (Admin and User)
PREDEFINED_USERS: Dict[str, Dict[str, Any]] = {
"admin": {
"passwords": ["admin12345", "admin123"],
"role": "admin",
"display_name": "System Administrator",
"email": "admin@nutritionintel.com",
},
"user": {
"passwords": ["user123", "store123"],
"role": "user",
"display_name": "Product & Store Manager",
"email": "user@nutritionintel.com",
},
}
ROLE_PERMISSIONS: Dict[str, List[str]] = {
"admin": ["view_catalog", "view_project_details", "upload_train_test", "allocate_discounts", "manage_analytics", "manage_nutrition"],
"user": ["add_product", "upload_batch_products", "update_db_and_json", "fetch_images", "upload_store_inventory", "view_store_analytics", "view_nutrition_insights", "optimize_profits"],
}
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int = Field(description="Token lifetime in seconds")
user: UserProfile
@router.post("/login", response_model=UserProfile)
def login(payload: LoginRequest) -> UserProfile:
"""Authenticate user with username and password (Admin or User)."""
un = payload.username.lower().strip()
pwd = payload.password.strip().lower()
target_role = (payload.role or "").lower().strip()
# A syntactically valid hash of an unguessable value. Never matches any real
# password; it exists only so the unknown-username path in login() does the
# same PBKDF2 work as the known one, keeping the two indistinguishable by timing.
_DUMMY_HASH = (
"pbkdf2_sha256$600000$YWJjZGVmZ2hpamtsbW5vcA==$"
"S1cVFrGD4pDkGqSjbEbaVSTONzGhCT9BOaWPQ2vwvvA="
)
# Check predefined usernames
if un in PREDEFINED_USERS:
user_info = PREDEFINED_USERS[un]
if pwd in user_info["passwords"] or pwd == "":
role = user_info["role"]
return UserProfile(
username=un,
role=role,
display_name=user_info["display_name"],
email=user_info["email"],
permissions=ROLE_PERMISSIONS.get(role, []),
def _accounts() -> Dict[str, dict]:
"""
The configured accounts, read per call so a settings reload is picked up.
Usernames are compared case-insensitively (matching what the login form
sends), but the password is not touched - the previous version lowercased
it before comparing, which silently shrank the effective keyspace.
"""
return {
AUTH_ADMIN_USERNAME.lower(): {
"password_hash": AUTH_ADMIN_PASSWORD_HASH,
"role": "admin",
"display_name": "System Administrator",
"email": "admin@nutritionintel.com",
},
AUTH_USER_USERNAME.lower(): {
"password_hash": AUTH_USER_PASSWORD_HASH,
"role": "user",
"display_name": "Product & Store Manager",
"email": "user@nutritionintel.com",
},
}
# ---------------------------------------------------------------------------
# Failed-login throttle
# ---------------------------------------------------------------------------
# In-process and per-worker: with several uvicorn workers a determined attacker
# gets AUTH_MAX_LOGIN_ATTEMPTS per worker, not overall. That is a real limit,
# not a rounding error - but it still turns an unbounded password oracle into a
# rate-limited one without adding Redis to the deployment. Move this to a shared
# store if you ever run many workers.
_failures: Dict[Tuple[str, str], Tuple[int, float]] = {}
_failures_lock = threading.Lock()
def _throttle_key(username: str, request: Request) -> Tuple[str, str]:
# request.client.host is the real client IP because uvicorn runs with
# --proxy-headers behind nginx/Caddy (see backend/Dockerfile); without that
# every request would appear to come from the proxy and share one bucket.
client = request.client.host if request.client else "unknown"
return (username, client)
def _check_not_locked(key: Tuple[str, str]) -> None:
with _failures_lock:
entry = _failures.get(key)
if entry is None:
return
count, first_seen = entry
if time.time() - first_seen > AUTH_LOCKOUT_SECONDS:
del _failures[key]
return
if count >= AUTH_MAX_LOGIN_ATTEMPTS:
retry_after = int(AUTH_LOCKOUT_SECONDS - (time.time() - first_seen))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Too many failed sign-in attempts. Try again in {retry_after}s.",
headers={"Retry-After": str(max(retry_after, 1))},
)
# Support role-based direct login (e.g. username 'Admin', 'User', 'Store')
if target_role in PREDEFINED_USERS or target_role == "store":
matched_key = "user" if target_role in ("user", "store") else target_role
user_info = PREDEFINED_USERS.get(matched_key, PREDEFINED_USERS["user"])
if pwd in user_info["passwords"] or pwd == "":
role = user_info["role"]
return UserProfile(
username=matched_key,
role=role,
display_name=user_info["display_name"],
email=user_info["email"],
permissions=ROLE_PERMISSIONS.get(role, []),
)
# Fallback for custom username
if un:
role = "admin" if target_role == "admin" else "user"
return UserProfile(
username=un,
role=role,
display_name=un.title(),
email=f"{un}@nutritionintel.com",
permissions=ROLE_PERMISSIONS.get(role, []),
def _record_failure(key: Tuple[str, str]) -> None:
now = time.time()
with _failures_lock:
count, first_seen = _failures.get(key, (0, now))
if now - first_seen > AUTH_LOCKOUT_SECONDS:
count, first_seen = 0, now
_failures[key] = (count + 1, first_seen)
def _clear_failures(key: Tuple[str, str]) -> None:
with _failures_lock:
_failures.pop(key, None)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request) -> LoginResponse:
"""Exchange a username and password for an access token."""
if not AUTH_ENABLED:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"Authentication is disabled on this server (AUTH_ENABLED=false), so no "
"token can be issued. Every endpoint is open; sign-in is not required."
),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials. Passwords: Admin (Admin12345), User (User123).",
username = payload.username.strip().lower()
key = _throttle_key(username, request)
_check_not_locked(key)
account = _accounts().get(username)
# Verify against a dummy hash when the username is unknown so a bad
# username and a bad password take the same time. Otherwise the response
# latency alone enumerates valid usernames.
stored_hash = account["password_hash"] if account else _DUMMY_HASH
password_ok = verify_password(payload.password, stored_hash)
if account is None or not password_ok:
_record_failure(key)
logger.warning("Failed sign-in for %r from %s", username, key[1])
# One message for both failure modes, for the same reason.
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password.",
)
_clear_failures(key)
role = account["role"]
permissions = ROLE_PERMISSIONS.get(role, [])
token, expires_in = create_access_token(username, role, permissions)
logger.info("Issued token for %r (role=%s)", username, role)
return LoginResponse(
access_token=token,
expires_in=expires_in,
user=UserProfile(
username=username,
role=role,
display_name=account["display_name"],
email=account["email"],
permissions=permissions,
),
)
@router.get("/me", response_model=UserProfile)
def me(principal: Principal = Depends(get_principal)) -> UserProfile:
"""
Who the presented credential belongs to. 401 if it is missing or expired.
The frontend calls this on boot to check a restored session before showing
the app, so an expired token lands on the login page rather than on a
dashboard whose every request then fails.
"""
account = _accounts().get(principal.username, {})
return UserProfile(
username=principal.username,
role=principal.role,
display_name=account.get("display_name", principal.username.title()),
email=account.get("email", f"{principal.username}@nutritionintel.com"),
permissions=principal.permissions,
)
@router.get("/roles")
def list_roles() -> dict:
"""Return available roles (Admin and User)."""
"""
The available roles and what each may do.
Note there are no demo credentials here any more. The passwords are set per
deployment via AUTH_ADMIN_PASSWORD_HASH / AUTH_USER_PASSWORD_HASH; this
endpoint used to publish working ones to anyone who asked.
"""
return {
"roles": [
{
"id": "admin",
"name": "Admin",
"description": "Full access: Catalog brand cards, existing project details, upload Excel/CSV train/test models, allocate discounts based on stock remaining, analytics & nutrition.",
"demo_username": "Admin",
"demo_password": "Admin12345",
"description": (
"Full access: catalog brand cards, project details, Excel/CSV "
"train/test uploads, discount allocation, analytics and nutrition. "
"Implicitly holds every permission."
),
"permissions": ROLE_PERMISSIONS["admin"],
},
{
"id": "user",
"name": "User",
"description": "Combined User & Store role: Upload single or batch CSV/Excel product entries with auto image & DB/JSON sync, store inventory management, profit analytics & nutrition.",
"demo_username": "User",
"demo_password": "User123",
"description": (
"Combined user and store role: single or batch product uploads with "
"image and DB/JSON sync, store inventory, profit analytics, nutrition."
),
"permissions": ROLE_PERMISSIONS["user"],
},
]
}

View File

@@ -3,9 +3,10 @@ from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from app.api.background import run_in_background
from app.api.deps import require_admin
from app.api.job_store import job_store
from app.api.schemas import CatalogGenerateRequest, CatalogJobOut
from app.core.ingestion import ingest_brand
@@ -24,7 +25,12 @@ async def _run_job(job_id: str, brand: str, max_products: int) -> None:
job_store.update(job_id, "failed", detail=str(e))
@router.post("/catalog/generate", response_model=CatalogJobOut, status_code=202)
@router.post(
"/catalog/generate",
response_model=CatalogJobOut,
status_code=202,
dependencies=[Depends(require_admin)],
)
def generate_catalog(payload: CatalogGenerateRequest) -> CatalogJobOut:
"""Kick off brand catalog ingestion (discovery -> images -> embeddings ->
pgvector) as a background daemon thread and return immediately with a job id.

View File

@@ -1,14 +1,22 @@
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from app.api.deps import require_authenticated
from app.api.schemas import ChatRequest, ChatResponseOut, SourceProductOut
from app.services.rag_service import answer_query
router = APIRouter(tags=["chat"])
@router.post("/chat", response_model=ChatResponseOut)
# Any signed-in role may chat, but anonymous callers may not: this is the only
# endpoint that spends Ollama time, and an unmetered LLM on a public host is
# what exhausts an 8GB VPS first.
@router.post(
"/chat",
response_model=ChatResponseOut,
dependencies=[Depends(require_authenticated)],
)
def chat(payload: ChatRequest) -> ChatResponseOut:
"""Conversational RAG endpoint: retrieves the most relevant products
from pgvector, then asks the local Ollama model to answer the

View File

@@ -2,10 +2,11 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.api.background import run_in_background
from app.api.deps import require_admin
from app.api.nutrition_job_store import nutrition_job_store
from app.services import nutrition_enrichment_service
@@ -54,7 +55,7 @@ def _run_train_job(job_id: str) -> None:
nutrition_job_store.update(job_id, status="failed", detail=str(e))
@router.post("/enrich", status_code=202)
@router.post("/enrich", status_code=202, dependencies=[Depends(require_admin)])
def enrich_nutrition(payload: EnrichRequest) -> dict:
"""Retrieves verified nutrition data for every product in the
catalog (Open Food Facts), computes transparent scores/insights, and
@@ -69,7 +70,7 @@ def enrich_nutrition(payload: EnrichRequest) -> dict:
return {"job_id": job.job_id, "status": job.status}
@router.post("/train", status_code=202)
@router.post("/train", status_code=202, dependencies=[Depends(require_admin)])
def train_nutrition_models() -> dict:
"""Trains the nutrition-similarity (KNN/cosine) and nutrition-based
clustering (KMeans) models over the currently enriched catalog.

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from app.api.background import run_in_background
from app.api.deps import require_admin
from app.api.store_job_store import store_job_store
from app.api.store_schemas import SeedRequest, TrainRequest
from app.services import ml_training_service, store_seed_service
@@ -33,7 +34,7 @@ def _run_train_job(job_id: str, models) -> None:
store_job_store.update(job_id, "failed", detail=str(e))
@router.post("/seed", status_code=202)
@router.post("/seed", status_code=202, dependencies=[Depends(require_admin)])
def seed_store_intelligence(payload: SeedRequest) -> dict:
"""Provisions the 5 stores + simulates order history (Features 1, 2, 8).
Requires at least one brand already ingested via the existing
@@ -47,7 +48,7 @@ def seed_store_intelligence(payload: SeedRequest) -> dict:
return {"job_id": job.job_id, "status": job.status}
@router.post("/train", status_code=202)
@router.post("/train", status_code=202, dependencies=[Depends(require_admin)])
def train_models(payload: TrainRequest) -> dict:
"""Trains every ML model (or a subset) against the seeded data.
Equivalent to running `python scripts/train_ml_models.py`."""

View File

@@ -7,9 +7,10 @@ import threading
from pathlib import Path
from typing import Any, Dict
from fastapi import APIRouter, BackgroundTasks
from fastapi import APIRouter, BackgroundTasks, Depends
from pydantic import BaseModel
from app.api.deps import require_admin
from app.services.vector_store import count_products_all_brands, list_available_brands, _connect
from app.services.store_db import list_stores
from app.services.ollama_service import _ensure_client
@@ -80,7 +81,7 @@ def get_system_status() -> SystemStatusOut:
)
@router.post("/system/init")
@router.post("/system/init", dependencies=[Depends(require_admin)])
def initialize_system(background_tasks: BackgroundTasks) -> Dict[str, Any]:
"""Trigger background auto-initialization of sample catalog and store data."""
background_tasks.add_task(_run_background_auto_seed)

View File

@@ -7,9 +7,10 @@ import pandas as pd
from typing import Any, Dict, List, Optional
from datetime import datetime
from fastapi import APIRouter, File, HTTPException, UploadFile, Response
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Response
from fastapi.responses import PlainTextResponse
from app.api.deps import require_permission
from app.services.vector_store import _connect
from app.services import store_db, nutrition_db
@@ -72,8 +73,10 @@ def _get_int(row: dict, keys: List[str], default: int = 0) -> int:
# ---------------------------------------------------------------------------
# Stores Inventory Excel / CSV Upload
# ---------------------------------------------------------------------------
@router.post("/stores")
@router.post("/stores/upload")
# Both aliases carry the guard. A dependency on only one decorator would leave
# the other path an unauthenticated route to the same function.
@router.post("/stores", dependencies=[Depends(require_permission("upload_store_inventory"))])
@router.post("/stores/upload", dependencies=[Depends(require_permission("upload_store_inventory"))])
async def upload_stores_file(file: UploadFile = File(...)) -> Dict[str, Any]:
if not file.filename:
raise HTTPException(status_code=400, detail="No file uploaded")
@@ -181,8 +184,8 @@ async def upload_stores_file(file: UploadFile = File(...)) -> Dict[str, Any]:
# ---------------------------------------------------------------------------
# Sales / Analytics Excel / CSV Upload
# ---------------------------------------------------------------------------
@router.post("/analytics")
@router.post("/analytics/upload")
@router.post("/analytics", dependencies=[Depends(require_permission("manage_analytics"))])
@router.post("/analytics/upload", dependencies=[Depends(require_permission("manage_analytics"))])
async def upload_analytics_file(file: UploadFile = File(...)) -> Dict[str, Any]:
if not file.filename:
raise HTTPException(status_code=400, detail="No file uploaded")
@@ -278,8 +281,8 @@ async def upload_analytics_file(file: UploadFile = File(...)) -> Dict[str, Any]:
# ---------------------------------------------------------------------------
# Nutrition Intelligence Excel / CSV Upload
# ---------------------------------------------------------------------------
@router.post("/nutrition")
@router.post("/nutrition/upload")
@router.post("/nutrition", dependencies=[Depends(require_permission("manage_nutrition"))])
@router.post("/nutrition/upload", dependencies=[Depends(require_permission("manage_nutrition"))])
async def upload_nutrition_file(file: UploadFile = File(...)) -> Dict[str, Any]:
if not file.filename:
raise HTTPException(status_code=400, detail="No file uploaded")

View File

@@ -6,7 +6,9 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
import pandas as pd
from pydantic import BaseModel, Field
from fastapi import APIRouter, HTTPException, File, UploadFile
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
from app.api.deps import require_permission
from app.services.vector_store import (
upsert_brand_products,
@@ -218,7 +220,7 @@ def _update_json_catalog_file(brand: str, product_dict: Dict[str, Any]) -> None:
logger.info("✅ Updated JSON seed file '%s' (total products: %d)", file_path.name, data["total_products"])
@router.post("/add", status_code=201)
@router.post("/add", status_code=201, dependencies=[Depends(require_permission("add_product"))])
def add_new_product(payload: AddProductRequest) -> dict:
"""User role endpoint: Add a single new product record (e.g. Lion Dates 450g).
Automatically enriches details, fetches images, updates PostgreSQL DB,
@@ -235,7 +237,11 @@ def add_new_product(payload: AddProductRequest) -> dict:
raise HTTPException(status_code=500, detail=f"Failed to add product: {e}")
@router.post("/batch-add", status_code=201)
@router.post(
"/batch-add",
status_code=201,
dependencies=[Depends(require_permission("upload_batch_products"))],
)
def batch_add_products(payload: BatchAddProductsRequest) -> dict:
"""User role endpoint: Batch upload multiple product records at once."""
added = []
@@ -256,7 +262,11 @@ def batch_add_products(payload: BatchAddProductsRequest) -> dict:
}
@router.post("/upload-file", status_code=201)
@router.post(
"/upload-file",
status_code=201,
dependencies=[Depends(require_permission("upload_batch_products"))],
)
async def upload_products_file(file: UploadFile = File(...)) -> dict:
"""User role endpoint: Upload CSV or Excel file containing products to enrich and sync."""
filename = file.filename or ""

View File

@@ -0,0 +1,256 @@
"""
Password hashing, access-token issuance/verification, and the Principal that
represents an authenticated caller.
Two kinds of credential reach this module:
* Interactive users. ``POST /api/auth/login`` exchanges a username/password
for a short-lived signed JWT. No password is ever stored - only a PBKDF2
digest, read from the environment (``AUTH_ADMIN_PASSWORD_HASH`` /
``AUTH_USER_PASSWORD_HASH``). Generate those with
``python scripts/make_auth_secrets.py``.
* Machine consumers. A static key sent as ``X-API-Key``, mapped to a role by
``API_KEYS``. These do not expire, so treat one as a long-lived secret and
give each consumer its own so it can be revoked individually.
PBKDF2-HMAC-SHA256 is used rather than bcrypt or argon2 deliberately: it is in
the standard library, so the slim Python image needs no compiled dependency,
and at the iteration count below it meets OWASP's current guidance. The
encoded form carries its own iteration count, so raising the constant later
does not invalidate hashes already issued.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import secrets
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import jwt
from app.infrastructure.settings import (
API_KEYS,
AUTH_ENABLED,
AUTH_SECRET_KEY,
AUTH_TOKEN_TTL_MINUTES,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Roles and permissions
# ---------------------------------------------------------------------------
# These mirror the permission strings the React UI already keys its navigation
# off, so the server now enforces the same vocabulary the client was only
# displaying. `admin` is a superuser: has_permission() grants it everything
# rather than requiring every new permission to be added to this list.
ROLE_PERMISSIONS: Dict[str, List[str]] = {
"admin": [
"view_catalog",
"view_project_details",
"upload_train_test",
"allocate_discounts",
"manage_analytics",
"manage_nutrition",
],
"user": [
"add_product",
"upload_batch_products",
"update_db_and_json",
"fetch_images",
"upload_store_inventory",
"view_store_analytics",
"view_nutrition_insights",
"optimize_profits",
],
}
VALID_ROLES = frozenset(ROLE_PERMISSIONS)
JWT_ALGORITHM = "HS256"
JWT_ISSUER = "brand-catalog-rag"
# OWASP's floor for PBKDF2-HMAC-SHA256 at time of writing.
_PBKDF2_ITERATIONS = 600_000
_PBKDF2_PREFIX = "pbkdf2_sha256"
@dataclass(frozen=True)
class Principal:
"""Whoever is making the current request, once their credential checks out."""
username: str
role: str
permissions: List[str] = field(default_factory=list)
# "user" - logged in via /api/auth/login, carrying a JWT
# "api_key" - a machine consumer from API_KEYS
# "anonymous" - AUTH_ENABLED=false; no credential was checked at all
kind: str = "user"
def has_permission(self, permission: str) -> bool:
return self.role == "admin" or permission in self.permissions
class AuthError(Exception):
"""A credential was absent, malformed, expired, or simply wrong."""
# ---------------------------------------------------------------------------
# Password hashing
# ---------------------------------------------------------------------------
def hash_password(password: str, *, iterations: int = _PBKDF2_ITERATIONS) -> str:
"""Return an encoded digest: ``pbkdf2_sha256$<iterations>$<salt>$<hash>``."""
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return "$".join(
(
_PBKDF2_PREFIX,
str(iterations),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(digest).decode("ascii"),
)
)
def verify_password(password: str, encoded: str) -> bool:
"""
Check a password against an encoded digest.
Returns False rather than raising on a malformed digest: a typo in
AUTH_ADMIN_PASSWORD_HASH must fail the login, not 500 the endpoint and
hand the caller a stack trace describing the credential store.
"""
if not encoded:
return False
try:
prefix, raw_iterations, raw_salt, raw_digest = encoded.split("$")
if prefix != _PBKDF2_PREFIX:
return False
expected = base64.b64decode(raw_salt), base64.b64decode(raw_digest)
salt, digest = expected
iterations = int(raw_iterations)
except (ValueError, TypeError):
logger.error(
"A configured password hash is malformed and cannot be used. Regenerate "
"it with: python scripts/make_auth_secrets.py"
)
return False
candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(candidate, digest)
# ---------------------------------------------------------------------------
# Access tokens
# ---------------------------------------------------------------------------
def create_access_token(
username: str,
role: str,
permissions: List[str],
*,
ttl_minutes: Optional[int] = None,
) -> tuple[str, int]:
"""Issue a signed JWT. Returns ``(token, expires_in_seconds)``."""
ttl = (ttl_minutes if ttl_minutes is not None else AUTH_TOKEN_TTL_MINUTES) * 60
now = int(time.time())
payload = {
"sub": username,
"role": role,
"perms": permissions,
"iss": JWT_ISSUER,
"iat": now,
"exp": now + ttl,
}
return jwt.encode(payload, AUTH_SECRET_KEY, algorithm=JWT_ALGORITHM), ttl
def decode_access_token(token: str) -> Principal:
"""
Verify a JWT and return the Principal it names.
The algorithm is pinned to a single-item allow-list rather than read from
the token header. That is what closes the two classic JWT bypasses: a token
presenting ``alg: none``, and one presenting ``alg: HS256`` against a key
the server intended to use asymmetrically.
"""
try:
payload = jwt.decode(
token,
AUTH_SECRET_KEY,
algorithms=[JWT_ALGORITHM],
issuer=JWT_ISSUER,
options={"require": ["exp", "iat", "sub"]},
)
except jwt.ExpiredSignatureError as exc:
raise AuthError("Token has expired. Sign in again.") from exc
except jwt.InvalidTokenError as exc:
raise AuthError("Invalid authentication token.") from exc
role = payload.get("role")
if role not in VALID_ROLES:
raise AuthError("Token names an unknown role.")
perms = payload.get("perms")
return Principal(
username=str(payload["sub"]),
role=role,
# Fall back to the role's current grants if the token predates a
# permission change, rather than trusting an arbitrary claim shape.
permissions=list(perms) if isinstance(perms, list) else ROLE_PERMISSIONS.get(role, []),
kind="user",
)
# ---------------------------------------------------------------------------
# API keys (machine consumers)
# ---------------------------------------------------------------------------
def principal_for_api_key(presented: str) -> Principal:
"""
Resolve an ``X-API-Key`` value to a Principal.
Every configured key is compared even after a match, using compare_digest,
so the time taken does not reveal how far down the list a near-miss got.
"""
matched: Optional[tuple[str, str]] = None
for secret, (name, role) in API_KEYS.items():
if hmac.compare_digest(presented, secret):
matched = (name, role)
if matched is None:
raise AuthError("Invalid API key.")
name, role = matched
return Principal(
username=name,
role=role,
permissions=ROLE_PERMISSIONS.get(role, []),
kind="api_key",
)
def anonymous_principal() -> Principal:
"""
The stand-in used when ``AUTH_ENABLED=false``.
It is deliberately an admin: disabling auth is meant to make local
development frictionless, and a half-privileged anonymous caller would
produce confusing 403s instead. Nothing calls this when auth is on.
"""
return Principal(
username="anonymous",
role="admin",
permissions=ROLE_PERMISSIONS["admin"],
kind="anonymous",
)
if not AUTH_ENABLED:
logger.warning(
"AUTH_ENABLED=false: every endpoint is unauthenticated, including catalog "
"generation, ML training, and the upload endpoints. This is for local "
"development only - never run it on a host reachable from the internet."
)

View File

@@ -134,6 +134,87 @@ API_CORS_ORIGINS = [
if origin.strip()
]
# ---------------------------------------------------------------------------
# Authentication
# ---------------------------------------------------------------------------
# CORS above is not access control - browsers enforce it, and curl ignores it
# entirely. These settings are what actually guards the write/compute endpoints
# (catalog generation, ML training, uploads, chat).
#
# AUTH_ENABLED=false turns every guard off, restoring the old behaviour where
# any caller could reach any endpoint. It exists so a fresh checkout still runs
# without generating secrets first; app/infrastructure/security.py logs a
# warning at import when it is off. Never deploy with it off.
AUTH_ENABLED = _bool("AUTH_ENABLED", "true")
# Signs and verifies access tokens. Changing it invalidates every issued token,
# which is the intended way to force everyone to sign in again. Generate with:
# python scripts/make_auth_secrets.py
AUTH_SECRET_KEY = (
_require("AUTH_SECRET_KEY", feature_flag="AUTH_ENABLED")
if AUTH_ENABLED
else os.getenv("AUTH_SECRET_KEY", "")
)
# How long an issued token stays valid. 12h by default: long enough that a
# working day needs one sign-in, short enough that a leaked token expires.
AUTH_TOKEN_TTL_MINUTES = int(os.getenv("AUTH_TOKEN_TTL_MINUTES", "720"))
# The two interactive accounts. Only PBKDF2 digests are stored - never a
# password. `make_auth_secrets.py` prints both lines ready to paste.
AUTH_ADMIN_USERNAME = os.getenv("AUTH_ADMIN_USERNAME", "admin")
AUTH_ADMIN_PASSWORD_HASH = (
_require("AUTH_ADMIN_PASSWORD_HASH", feature_flag="AUTH_ENABLED")
if AUTH_ENABLED
else os.getenv("AUTH_ADMIN_PASSWORD_HASH", "")
)
AUTH_USER_USERNAME = os.getenv("AUTH_USER_USERNAME", "user")
AUTH_USER_PASSWORD_HASH = (
_require("AUTH_USER_PASSWORD_HASH", feature_flag="AUTH_ENABLED")
if AUTH_ENABLED
else os.getenv("AUTH_USER_PASSWORD_HASH", "")
)
# Failed-login throttle, applied per username+client-IP. Prevents an exposed
# login endpoint from being a free password oracle.
AUTH_MAX_LOGIN_ATTEMPTS = int(os.getenv("AUTH_MAX_LOGIN_ATTEMPTS", "10"))
AUTH_LOCKOUT_SECONDS = int(os.getenv("AUTH_LOCKOUT_SECONDS", "300"))
def _parse_api_keys(raw: str) -> dict:
"""
Parse ``API_KEYS`` - ``name:role:secret`` triples, comma-separated.
Keyed by secret because that is what an inbound request presents. One entry
per consumer is the point: a shared key cannot be revoked for one caller
without breaking all of them.
"""
parsed: dict = {}
for entry in raw.split(","):
entry = entry.strip()
if not entry:
continue
parts = entry.split(":")
if len(parts) != 3:
raise RuntimeError(
f"Malformed API_KEYS entry {entry!r}. Expected 'name:role:secret', "
f"comma-separated between entries."
)
name, role, secret = (p.strip() for p in parts)
if role not in {"admin", "user"}:
raise RuntimeError(
f"API_KEYS entry {name!r} has role {role!r}; expected 'admin' or 'user'."
)
if not secret:
raise RuntimeError(f"API_KEYS entry {name!r} has an empty secret.")
parsed[secret] = (name, role)
return parsed
# Machine consumers of api.<domain>. Empty by default - browser sessions go
# through /api/auth/login instead, and a key that nobody needs is only risk.
API_KEYS = _parse_api_keys(os.getenv("API_KEYS", ""))
# Default RAG behaviour
RAG_DEFAULT_TOP_K = int(os.getenv("RAG_DEFAULT_TOP_K", "5"))
RAG_MAX_TOP_K = int(os.getenv("RAG_MAX_TOP_K", "15"))

View File

@@ -10,9 +10,10 @@ from __future__ import annotations
import logging
import threading
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
@@ -31,19 +32,17 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
app = FastAPI(
title="Brand Product Search Engine - RAG API",
description=(
"Local, CPU-only RAG API over an Indian FMCG product catalog stored in pgvector. "
"Includes automated multi-store intelligence, ML discount engines, and nutrition intelligence."
),
version="3.2.0",
)
@asynccontextmanager
async def lifespan(_app: FastAPI):
"""
Schema checks and auto-seeding, kicked off without blocking startup.
The work runs on a daemon thread rather than being awaited: it touches
Postgres, which may be slow or briefly unreachable on a cold boot, and the
server answering /api/health in under a second is what lets the container
healthcheck pass while that settles.
"""
@app.on_event("startup")
def _on_startup() -> None:
# Asynchronously ensure schemas and background auto-init so server starts instantly (<1s)
def _async_init():
try:
ensure_store_intelligence_schema()
@@ -54,12 +53,42 @@ def _on_startup() -> None:
logger.warning("Startup background init warning: %s", e)
threading.Thread(target=_async_init, daemon=True).start()
yield
app = FastAPI(
title="Brand Product Search Engine - RAG API",
description=(
"Local, CPU-only RAG API over an Indian FMCG product catalog stored in pgvector. "
"Includes automated multi-store intelligence, ML discount engines, and nutrition intelligence."
),
version="3.2.0",
lifespan=lifespan,
)
# When the app is served same-origin (the frontend's nginx proxies /api/* to
# this service), API_CORS_ORIGINS is empty and no cross-origin request is ever
# made. It is populated only when the API is also published on its own host -
# see api.{$DOMAIN} in the Caddyfile.
#
# A wildcard origin and credentialed requests are mutually exclusive under the
# CORS spec: browsers reject `Access-Control-Allow-Origin: *` on any request
# carrying credentials. Sending both is a silent misconfiguration - the server
# looks configured while every browser call fails - so a wildcard turns
# credentials off explicitly and says so in the log.
_allow_credentials = "*" not in API_CORS_ORIGINS
if not _allow_credentials:
logger.warning(
"API_CORS_ORIGINS contains '*': disabling allow_credentials, because "
"browsers reject credentialed cross-origin requests to a wildcard origin. "
"List the exact origins instead if you need cookies or Authorization headers."
)
app.add_middleware(
CORSMiddleware,
allow_origins=API_CORS_ORIGINS,
allow_credentials=True,
allow_credentials=_allow_credentials,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -90,8 +119,14 @@ if FRONTEND_DIST.exists() and (FRONTEND_DIST / "assets").exists():
@app.get("/{full_path:path}")
def serve_frontend(full_path: str):
if full_path.startswith("api") or full_path.startswith("docs") or full_path.startswith("openapi.json"):
return None
# API and docs paths are served by the routers registered above, so
# this catch-all only sees them when the path genuinely doesn't exist.
# Returning None there would answer 200 with a `null` body - an unknown
# endpoint would look like a successful call to any client. 404 is the
# honest answer, and it matters now that the API is also reachable
# directly at api.{$DOMAIN} rather than only behind the frontend.
if full_path.startswith(("api", "docs", "redoc", "openapi.json")):
raise HTTPException(status_code=404, detail="Not found")
file_path = FRONTEND_DIST / full_path
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)