Electronics Catalog: API, MCP server, frontend and deployment
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>
This commit is contained in:
0
backend/app/infrastructure/__init__.py
Normal file
0
backend/app/infrastructure/__init__.py
Normal file
427
backend/app/infrastructure/security.py
Normal file
427
backend/app/infrastructure/security.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
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, Tuple
|
||||
|
||||
import jwt
|
||||
|
||||
from app.infrastructure.settings import (
|
||||
API_KEYS,
|
||||
AUTH_ADMIN_PASSWORD_HASH,
|
||||
AUTH_ADMIN_USERNAME,
|
||||
AUTH_ALLOW_ANY_LOGIN,
|
||||
AUTH_ENABLED,
|
||||
AUTH_SECRET_KEY,
|
||||
AUTH_TOKEN_TTL_MINUTES,
|
||||
config_source,
|
||||
)
|
||||
|
||||
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",
|
||||
],
|
||||
# An outside API client that may send spreadsheets for catalog ingestion and
|
||||
# do NOTHING else. One permission, deliberately.
|
||||
#
|
||||
# This role exists because API keys carry no per-key scoping:
|
||||
# principal_for_api_key() derives permissions entirely from the role, so
|
||||
# "upload-only" can only be expressed as a role. Reusing `user` would have
|
||||
# been less code and would also have handed an outside contributor
|
||||
# add_product, upload_batch_products and upload_store_inventory - real
|
||||
# write access to the catalog - to solve a problem that needed one verb.
|
||||
#
|
||||
# WHAT A LEAKED UPLOADER KEY COSTS. Real CPU: this permission starts the
|
||||
# 11-stage pipeline, which is the point of the endpoint. The bound is not
|
||||
# "this role cannot work" but "all ingestion, from every source, shares one
|
||||
# worker" - batch_worker runs a single batch at a time behind a queue of
|
||||
# BATCH_QUEUE_MAX, past which POST /api/uploads/catalog answers 429. So a
|
||||
# key can occupy the ingestion worker; it cannot multiply it, and it cannot
|
||||
# touch the request path the healthcheck reads.
|
||||
#
|
||||
# What it still cannot do: read the catalog, read another caller's
|
||||
# submissions (every read on that router is filtered by submitted_by), or
|
||||
# cancel, resume or delete anything.
|
||||
"uploader": [
|
||||
"upload_catalog",
|
||||
],
|
||||
}
|
||||
|
||||
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 _parse_encoded_hash(encoded: str) -> Optional[Tuple[bytes, bytes, int]]:
|
||||
"""
|
||||
Split an encoded digest into ``(salt, digest, iterations)``, or None if it
|
||||
is not one.
|
||||
|
||||
One parser, three callers. `verify_password` needs the parts, while
|
||||
`hash_is_wellformed` and `describe_password_hash` need only the verdict -
|
||||
and a login failing because the *configured* hash is corrupt is a different
|
||||
incident from a wrong password, so the two must agree on what "corrupt"
|
||||
means. Two copies of this parse would eventually disagree.
|
||||
|
||||
Values arrive here straight from the environment, so a hash pasted into a
|
||||
deployment platform's form field as "pbkdf2_sha256$..." is unwrapped rather
|
||||
than rejected: the surrounding quotes are almost never intended as part of
|
||||
the secret, and the failure they cause otherwise is a silent 401.
|
||||
"""
|
||||
if not encoded:
|
||||
return None
|
||||
encoded = encoded.strip().strip("'\"")
|
||||
try:
|
||||
prefix, raw_iterations, raw_salt, raw_digest = encoded.split("$")
|
||||
if prefix != _PBKDF2_PREFIX:
|
||||
return None
|
||||
# validate=True so junk is rejected rather than silently discarded:
|
||||
# b64decode's default drops non-alphabet characters, which would let a
|
||||
# subtly corrupted hash decode to the wrong bytes and fail as a "wrong
|
||||
# password" instead of as the configuration error it is.
|
||||
# binascii.Error subclasses ValueError, so it is caught below.
|
||||
salt = base64.b64decode(raw_salt, validate=True)
|
||||
digest = base64.b64decode(raw_digest, validate=True)
|
||||
iterations = int(raw_iterations)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
# A structurally valid string that decodes to nothing is still unusable,
|
||||
# and PBKDF2 rejects a non-positive iteration count by raising.
|
||||
if not salt or not digest or iterations < 1:
|
||||
return None
|
||||
return salt, digest, iterations
|
||||
|
||||
|
||||
def hash_is_wellformed(encoded: str) -> bool:
|
||||
"""Whether a configured digest can be checked against at all.
|
||||
|
||||
Distinct from "does the password match": this asks whether the credential
|
||||
*store* is usable, which is a deployment fault rather than a sign-in one.
|
||||
"""
|
||||
return _parse_encoded_hash(encoded) is not None
|
||||
|
||||
|
||||
def password_hash_fingerprint(encoded: str) -> str:
|
||||
"""
|
||||
A short, non-reversible identifier for a configured digest.
|
||||
|
||||
Safe to log and to publish: it is a truncated SHA-256 of the *encoded
|
||||
digest*, and that digest already embeds a 16-byte random salt, so this says
|
||||
which credential is loaded without saying anything about the password
|
||||
behind it. It exists so a running deployment can be compared against the
|
||||
config it was supposed to have been built from - the failure this project
|
||||
actually hit - without moving a secret in order to do the comparison.
|
||||
"""
|
||||
if not encoded:
|
||||
return ""
|
||||
return hashlib.sha256(encoded.strip().strip("'\"").encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def api_key_fingerprint(name: str, secret: str) -> str:
|
||||
"""
|
||||
A short, non-reversible identifier for a configured API key.
|
||||
|
||||
Same purpose as password_hash_fingerprint - say *which* credential is loaded
|
||||
without moving the credential - but the safety argument is different and
|
||||
worth stating. That function digests an encoded hash which already embeds a
|
||||
16-byte random salt. An API key has no salt, so the name is mixed in here to
|
||||
keep two consumers that were mistakenly issued the same secret from
|
||||
fingerprinting identically, and settings._parse_api_keys enforces a minimum
|
||||
secret length so the digest cannot be walked back with a wordlist.
|
||||
"""
|
||||
if not secret:
|
||||
return ""
|
||||
cleaned = secret.strip().strip("'\"")
|
||||
material = f"{name}:{cleaned}"
|
||||
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def describe_api_keys() -> List[Dict[str, object]]:
|
||||
"""Every configured key as {name, role, fingerprint}, sorted by name.
|
||||
|
||||
Sorted so two deployments' /api/health output can be diffed line for line;
|
||||
API_KEYS is keyed by secret, whose iteration order says nothing useful.
|
||||
"""
|
||||
return sorted(
|
||||
(
|
||||
{"name": name, "role": role, "fingerprint": api_key_fingerprint(name, secret)}
|
||||
for secret, (name, role) in API_KEYS.items()
|
||||
),
|
||||
key=lambda entry: entry["name"],
|
||||
)
|
||||
|
||||
|
||||
def describe_password_hash(encoded: str) -> Dict[str, object]:
|
||||
"""A loggable/publishable summary of a configured digest. Never its bytes."""
|
||||
parsed = _parse_encoded_hash(encoded)
|
||||
return {
|
||||
"valid": parsed is not None,
|
||||
"algorithm": _PBKDF2_PREFIX if parsed is not None else None,
|
||||
"iterations": parsed[2] if parsed is not None else None,
|
||||
"fingerprint": password_hash_fingerprint(encoded),
|
||||
}
|
||||
|
||||
|
||||
def auth_config_summary() -> Dict[str, object]:
|
||||
"""
|
||||
The effective authentication configuration, in a form safe to both log and
|
||||
publish. Contains no password and no hash - only the fingerprint.
|
||||
|
||||
This is deliberately one function with two callers (the startup log in
|
||||
app/main.py and GET /api/health), because its entire purpose is letting two
|
||||
*deployments* be compared, and that only works if both report the same
|
||||
fields computed the same way.
|
||||
|
||||
`*_source` is the field that earns this its keep. A value of "process-env"
|
||||
means the container's own environment supplied it and the .env file baked
|
||||
into the image was ignored - which is invisible from anywhere else, and is
|
||||
precisely how a corrected credential can keep failing after a redeploy.
|
||||
|
||||
The same argument is why the API keys are summarised here. backend/Dockerfile
|
||||
copies .env.production in at BUILD time, so a key added to that file and then
|
||||
merely restarted is not present in the running process - and from outside,
|
||||
an undeployed key is indistinguishable from a wrong one, because both are
|
||||
just a 401. Publishing the names and fingerprints answers "is my key on this
|
||||
deployment?" without anyone having to send the secret to find out.
|
||||
"""
|
||||
described = describe_password_hash(AUTH_ADMIN_PASSWORD_HASH)
|
||||
return {
|
||||
"enabled": AUTH_ENABLED,
|
||||
"allow_any_login": AUTH_ALLOW_ANY_LOGIN,
|
||||
"admin_username": AUTH_ADMIN_USERNAME,
|
||||
"password_hash_valid": bool(described["valid"]),
|
||||
"password_hash_iterations": described["iterations"],
|
||||
"password_hash_fingerprint": described["fingerprint"],
|
||||
"admin_username_source": config_source("AUTH_ADMIN_USERNAME"),
|
||||
"password_hash_source": config_source("AUTH_ADMIN_PASSWORD_HASH"),
|
||||
"api_keys_count": len(API_KEYS),
|
||||
"api_keys": describe_api_keys(),
|
||||
"api_keys_source": config_source("API_KEYS"),
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
parsed = _parse_encoded_hash(encoded)
|
||||
if parsed is None:
|
||||
logger.error(
|
||||
"A configured password hash is malformed and cannot be used. Regenerate "
|
||||
"it with: python scripts/make_auth_secrets.py"
|
||||
)
|
||||
return False
|
||||
|
||||
salt, digest, iterations = parsed
|
||||
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."
|
||||
)
|
||||
373
backend/app/infrastructure/settings.py
Normal file
373
backend/app/infrastructure/settings.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Centralized configuration for the Electronics Catalog backend.
|
||||
|
||||
Every credential is read ONLY from the environment (backend/.env via
|
||||
python-dotenv, or real OS variables). Non-secret values keep safe local
|
||||
defaults.
|
||||
|
||||
LOCAL-ONLY GUARD
|
||||
----------------
|
||||
This project is a copy of the grocery catalogue, whose .env files pointed at a
|
||||
remote production database. To make it impossible to write electronics data
|
||||
there by accident, settings refuse to load unless DB_HOST is a local host and
|
||||
DB_NAME is the dedicated electronics database. See _guard_local_database().
|
||||
The one exception is an explicit production opt-in (ELEC_ALLOW_REMOTE_DB plus
|
||||
an exact host/database allowlist), used only by the production deployment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Snapshotted BEFORE load_dotenv, and that ordering is the entire point.
|
||||
# load_dotenv() is called without override=True, so a variable already in the
|
||||
# process environment silently beats the .env file and keeps beating it no
|
||||
# matter how many times the file is corrected. That is not hypothetical here:
|
||||
# the deployment platform injects its Environment tab into the container, so a
|
||||
# stale value left in that tab overrides the credentials baked into the image
|
||||
# (backend/Dockerfile copies .env.production to /app/.env) and the only symptom
|
||||
# is a 401 that nothing explains. Comparing a name against this set answers
|
||||
# "which of the two won?" - see config_source() below.
|
||||
_PREEXISTING_ENV = frozenset(os.environ)
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# backend/.env (one level up from this file: app/infrastructure/settings.py)
|
||||
_env_path = Path(__file__).resolve().parents[2] / ".env"
|
||||
load_dotenv(_env_path)
|
||||
except ImportError:
|
||||
# python-dotenv not installed - fall back to whatever is already in the
|
||||
# process environment (e.g. set by the shell, Docker, systemd, CI, etc.)
|
||||
pass
|
||||
|
||||
|
||||
# Names whose raw value arrived wrapped in quotes or padded with whitespace.
|
||||
# Recorded rather than merely fixed: stripping keeps the login working, but the
|
||||
# only place the original shape is still visible is right here, before the value
|
||||
# is normalised. A quoted hash is the signature of a value pasted into a web
|
||||
# form, so surfacing it at startup is what stops the next person rediscovering
|
||||
# it from a 401. See DB_PASSWORD in .env.production for the counter-case where
|
||||
# the quotes ARE part of the secret - which is why this warns, and does not fail.
|
||||
_ENV_NEEDED_CLEANUP = set()
|
||||
|
||||
|
||||
def _clean(name: str, raw: str) -> str:
|
||||
"""Strip surrounding quotes/whitespace off an env value, remembering if it mattered."""
|
||||
cleaned = raw.strip().strip("'\"")
|
||||
if cleaned != raw:
|
||||
_ENV_NEEDED_CLEANUP.add(name)
|
||||
return cleaned
|
||||
|
||||
|
||||
def cleaned_env_names() -> list:
|
||||
"""Which settings needed quote/whitespace stripping. Reported at startup."""
|
||||
return sorted(_ENV_NEEDED_CLEANUP)
|
||||
|
||||
|
||||
def config_source(name: str) -> str:
|
||||
"""
|
||||
Where a setting's value actually came from: the process environment, the
|
||||
.env file, or this module's own default.
|
||||
|
||||
Reported at startup for the AUTH_* values (see app/main.py) so that an
|
||||
override arriving from outside the image is visible in the logs instead of
|
||||
being inferred from a failing login.
|
||||
"""
|
||||
if name in _PREEXISTING_ENV:
|
||||
return "process-env"
|
||||
if name in os.environ:
|
||||
return "env-file"
|
||||
return "default"
|
||||
|
||||
|
||||
def _bool(name: str, default: str) -> bool:
|
||||
return os.getenv(name, default).strip().lower() in {"1", "true", "yes"}
|
||||
|
||||
|
||||
def _require(name: str, *, feature_flag: str) -> str:
|
||||
"""Read a required secret. Raises if missing and the owning feature is enabled."""
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise RuntimeError(
|
||||
f"Missing required environment variable '{name}'. It is required because "
|
||||
f"'{feature_flag}' is enabled. Set it in backend/.env (copy from "
|
||||
f".env.example) or disable the feature by setting {feature_flag}=false."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "").strip() or _BACKEND_ROOT / "data")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ollama (local LLM) - only ever used to read text we fetched, never to invent
|
||||
# ---------------------------------------------------------------------------
|
||||
USE_OLLAMA = _bool("USE_OLLAMA", "true")
|
||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL_NAME = os.getenv("OLLAMA_MODEL_NAME", "qwen2.5:1.5b")
|
||||
OLLAMA_TIMEOUT_SECONDS = int(os.getenv("OLLAMA_TIMEOUT_SECONDS", "120"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embeddings (sentence-transformers, CPU-friendly)
|
||||
# ---------------------------------------------------------------------------
|
||||
USE_EMBEDDINGS = _bool("USE_EMBEDDINGS", "true")
|
||||
EMBEDDINGS_MODEL = os.getenv("EMBEDDINGS_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
|
||||
EMBEDDINGS_DIM = int(os.getenv("EMBEDDINGS_DIM", "384"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Postgres / pgvector - the LOCAL electronics database only
|
||||
# ---------------------------------------------------------------------------
|
||||
ELECTRONICS_DB_NAME = "electronics_catalog"
|
||||
# The test suite uses its own database on the same local server.
|
||||
ALLOWED_DB_NAMES = frozenset({ELECTRONICS_DB_NAME, ELECTRONICS_DB_NAME + "_test"})
|
||||
LOCAL_DB_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "host.docker.internal", "postgres"})
|
||||
|
||||
DB_HOST = os.getenv("DB_HOST", "127.0.0.1").strip()
|
||||
DB_PORT = os.getenv("DB_PORT", "5433").strip()
|
||||
DB_NAME = os.getenv("DB_NAME", ELECTRONICS_DB_NAME).strip()
|
||||
DB_USER = os.getenv("DB_USER", "postgres").strip()
|
||||
DB_PASSWORD = _require("DB_PASSWORD", feature_flag="the electronics database")
|
||||
DB_CONNECT_TIMEOUT_SECONDS = int(os.getenv("DB_CONNECT_TIMEOUT_SECONDS", "5"))
|
||||
|
||||
|
||||
def _csv_set(name: str) -> frozenset:
|
||||
return frozenset(v.strip() for v in os.getenv(name, "").split(",") if v.strip())
|
||||
|
||||
|
||||
# Production opt-in. Off by default: without ELEC_ALLOW_REMOTE_DB=true the
|
||||
# guard below behaves exactly as it always has. With it on, only the host(s)
|
||||
# and database name(s) listed here are accepted - never "any remote host".
|
||||
ELEC_ALLOW_REMOTE_DB = _bool("ELEC_ALLOW_REMOTE_DB", "false")
|
||||
ELEC_REMOTE_DB_HOSTS = _csv_set("ELEC_REMOTE_DB_HOSTS")
|
||||
ELEC_REMOTE_DB_NAMES = _csv_set("ELEC_REMOTE_DB_NAMES")
|
||||
|
||||
|
||||
def _guard_local_database(host: str, name: str, *, allow_remote: bool = False,
|
||||
remote_hosts: frozenset = frozenset(), remote_names: frozenset = frozenset()) -> None:
|
||||
"""Refuse to run against anything but the local electronics database,
|
||||
unless the production opt-in names this exact host and database."""
|
||||
if allow_remote and host in remote_hosts:
|
||||
if name not in remote_names:
|
||||
raise RuntimeError(
|
||||
f"DB_NAME={name!r} is not in ELEC_REMOTE_DB_NAMES for remote host {host!r}."
|
||||
)
|
||||
return
|
||||
if host not in LOCAL_DB_HOSTS:
|
||||
raise RuntimeError(
|
||||
f"DB_HOST={host!r} is not a local host. This project only runs against the "
|
||||
f"local Docker database (see docker-compose.yml); it must never touch the "
|
||||
f"remote catalogue database."
|
||||
)
|
||||
if name not in ALLOWED_DB_NAMES:
|
||||
raise RuntimeError(
|
||||
f"DB_NAME={name!r}; expected {ELECTRONICS_DB_NAME!r}. The electronics data "
|
||||
f"lives in its own database so existing databases are never modified."
|
||||
)
|
||||
|
||||
|
||||
_guard_local_database(DB_HOST, DB_NAME, allow_remote=ELEC_ALLOW_REMOTE_DB,
|
||||
remote_hosts=ELEC_REMOTE_DB_HOSTS, remote_names=ELEC_REMOTE_DB_NAMES)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web search (discovery, prices, images)
|
||||
# ---------------------------------------------------------------------------
|
||||
# DuckDuckGo needs no key. Google Programmable Search is used in addition when
|
||||
# both GOOGLE_API_KEY and GOOGLE_CSE_ID are set (100 free queries/day).
|
||||
USE_DDG_SEARCH = _bool("USE_DDG_SEARCH", "true")
|
||||
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "").strip()
|
||||
GOOGLE_CSE_ID = os.getenv("GOOGLE_CSE_ID", "").strip()
|
||||
USE_GOOGLE_CSE = bool(GOOGLE_API_KEY and GOOGLE_CSE_ID) and _bool("USE_GOOGLE_CSE", "true")
|
||||
GOOGLE_CSE_DAILY_QUOTA = int(os.getenv("GOOGLE_CSE_DAILY_QUOTA", "100"))
|
||||
SEARCH_REGION = os.getenv("SEARCH_REGION", "in-en")
|
||||
# Minimum pause between two search queries to the same provider.
|
||||
SEARCH_MIN_INTERVAL_SECONDS = float(os.getenv("SEARCH_MIN_INTERVAL_SECONDS", "2.5"))
|
||||
SEARCH_CACHE_TTL_HOURS = int(os.getenv("SEARCH_CACHE_TTL_HOURS", "24"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polite fetching of retailer / brand pages
|
||||
# ---------------------------------------------------------------------------
|
||||
# An honest User-Agent with a contact address. Set ELEC_CONTACT to a real
|
||||
# address before running a crawl.
|
||||
ELEC_CONTACT = os.getenv("ELEC_CONTACT", "admin@example.com").strip()
|
||||
USER_AGENT = os.getenv(
|
||||
"USER_AGENT", f"ElectronicsCatalogBot/0.1 (+mailto:{ELEC_CONTACT}; local research)"
|
||||
)
|
||||
REQUEST_TIMEOUT_SECONDS = int(os.getenv("REQUEST_TIMEOUT_SECONDS", "20"))
|
||||
ELEC_SITE_MIN_INTERVAL_SECONDS = float(os.getenv("ELEC_SITE_MIN_INTERVAL_SECONDS", "3"))
|
||||
ELEC_BREAKER_COOLDOWN_HOURS = float(os.getenv("ELEC_BREAKER_COOLDOWN_HOURS", "24"))
|
||||
ELEC_MAX_PAGE_BYTES = int(os.getenv("ELEC_MAX_PAGE_BYTES", str(3 * 1024 * 1024)))
|
||||
ELEC_PROBE_TTL_DAYS = int(os.getenv("ELEC_PROBE_TTL_DAYS", "7"))
|
||||
MIN_IMAGE_BYTES = int(os.getenv("MIN_IMAGE_BYTES", "3000"))
|
||||
|
||||
# Reference pincodes (Tamil Nadu). "pincode:City" pairs, comma-separated. The
|
||||
# first one is the default. A pincode is stored against a price only when the
|
||||
# site actually accepted it.
|
||||
ELEC_REFERENCE_PINCODES = [
|
||||
tuple(p.split(":", 1)) if ":" in p else (p, "")
|
||||
for p in (x.strip() for x in os.getenv("ELEC_REFERENCE_PINCODES", "641001:Coimbatore,600001:Chennai").split(","))
|
||||
if p
|
||||
]
|
||||
|
||||
# The LLM may only fill spec gaps from text we fetched; set false to run fully
|
||||
# deterministic.
|
||||
ELEC_USE_LLM = _bool("ELEC_USE_LLM", "true")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI / web server
|
||||
# ---------------------------------------------------------------------------
|
||||
API_CORS_ORIGINS = [
|
||||
origin.strip()
|
||||
for origin in os.getenv("API_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(",")
|
||||
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 interactive accounts. Only PBKDF2 digests are stored - never a password.
|
||||
# `make_auth_secrets.py` prints the lines ready to paste.
|
||||
#
|
||||
# `admin` is required whenever auth is on: without it nobody could sign in.
|
||||
AUTH_ADMIN_USERNAME = _clean(
|
||||
"AUTH_ADMIN_USERNAME", os.getenv("AUTH_ADMIN_USERNAME", "admin")
|
||||
)
|
||||
AUTH_ADMIN_PASSWORD_HASH = _clean(
|
||||
"AUTH_ADMIN_PASSWORD_HASH",
|
||||
(
|
||||
_require("AUTH_ADMIN_PASSWORD_HASH", feature_flag="AUTH_ENABLED")
|
||||
if AUTH_ENABLED
|
||||
else os.getenv("AUTH_ADMIN_PASSWORD_HASH", "")
|
||||
),
|
||||
)
|
||||
|
||||
# The second `user` account is OPTIONAL, and left unset in this deployment.
|
||||
# An empty hash is how the account is switched off: auth.py builds its account
|
||||
# table from these values and omits any entry whose hash is blank, so there is
|
||||
# nothing to sign in to. Setting the hash again re-enables it with no code
|
||||
# change - which is exactly what the test suite does in tests/conftest.py.
|
||||
AUTH_USER_USERNAME = _clean(
|
||||
"AUTH_USER_USERNAME", os.getenv("AUTH_USER_USERNAME", "user")
|
||||
)
|
||||
AUTH_USER_PASSWORD_HASH = _clean(
|
||||
"AUTH_USER_PASSWORD_HASH", 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"))
|
||||
|
||||
# Local-development escape hatch: accept ANY password at /api/auth/login, so a
|
||||
# developer who does not have the configured passwords to hand can still reach
|
||||
# the admin and user pages. The username still selects the role, and the token
|
||||
# issued is a normal signed one - so every downstream guard, /api/auth/me, and
|
||||
# the React route gating all behave exactly as they do in production. What is
|
||||
# skipped is only the password check.
|
||||
#
|
||||
# This is NOT the same as AUTH_ENABLED=false. That disables every guard *and*
|
||||
# makes /api/auth/login return 503, which breaks the login page outright. This
|
||||
# flag keeps the whole auth machinery running and unlocks just the front door.
|
||||
#
|
||||
# Anyone who can reach the API can sign in as admin while it is on. Keep it
|
||||
# false anywhere the port is reachable by someone you would not hand the admin
|
||||
# password to.
|
||||
AUTH_ALLOW_ANY_LOGIN = _bool("AUTH_ALLOW_ANY_LOGIN", "false")
|
||||
|
||||
|
||||
# Shortest acceptable API key secret. token_urlsafe(32) yields 43 characters, so
|
||||
# this rejects hand-typed values without rejecting anything the documented
|
||||
# generator produces.
|
||||
API_KEY_MIN_LENGTH = 32
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Secrets must be at least API_KEY_MIN_LENGTH characters. That is not about
|
||||
guessing the key over the network - the lockout and the network itself make
|
||||
online brute force impractical - but about what /api/health publishes. It
|
||||
reports a truncated digest of every configured key so a deployment can be
|
||||
checked against the config it was built from, and a digest of a *raw* secret
|
||||
is only safe when the secret is unguessable offline. An admin password hash
|
||||
embeds a random salt, so its fingerprint discloses nothing; an API key has no
|
||||
salt, and a hand-picked "changeme" would fall to a wordlist in seconds.
|
||||
Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
"""
|
||||
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)
|
||||
# MUST stay in step with ROLE_PERMISSIONS in app/infrastructure/security.py,
|
||||
# which is the source of truth. It is duplicated rather than imported
|
||||
# because security.py imports THIS module, so importing it back here
|
||||
# would be a cycle. A role added there but not here is rejected at boot
|
||||
# with the message below - loud, and before any request is served.
|
||||
if role not in {"admin", "user", "uploader"}:
|
||||
raise RuntimeError(
|
||||
f"API_KEYS entry {name!r} has role {role!r}; expected 'admin', 'user' "
|
||||
f"or 'uploader'."
|
||||
)
|
||||
if not secret:
|
||||
raise RuntimeError(f"API_KEYS entry {name!r} has an empty secret.")
|
||||
if len(secret) < API_KEY_MIN_LENGTH:
|
||||
raise RuntimeError(
|
||||
f"API_KEYS entry {name!r} has a {len(secret)}-character secret; at least "
|
||||
f"{API_KEY_MIN_LENGTH} are required, because /api/health publishes a digest "
|
||||
f"of it. Generate one with: "
|
||||
f"python -c \"import secrets; print(secrets.token_urlsafe(32))\""
|
||||
)
|
||||
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.
|
||||
#
|
||||
# NAME THE KEY FOR ITS FUNCTION, NOT THE PERSON HOLDING IT.
|
||||
# /api/health is public and reports {name, role, fingerprint} for every
|
||||
# configured key (describe_api_keys in security.py). The secret is never
|
||||
# exposed, but the NAME is - so `catalog-drop:uploader:...` is right and
|
||||
# `priya-laptop:uploader:...` publishes a colleague's name to anyone who
|
||||
# curls the health endpoint.
|
||||
API_KEYS = _parse_api_keys(os.getenv("API_KEYS", ""))
|
||||
|
||||
Reference in New Issue
Block a user