Files
sales-trainer/backend/app/api/helpers.py
Macky 2c40fc7502 feat(import): commit S4.4 JSON→PostgreSQL importer + API error-handler hardening
Re-verified staged increment from a clean requirements.lock.txt venv:
- 330 backend tests pass (17/17 in new error_handlers + json_import tests)
- compileall + frontend npm build clean
- git diff --check clean; no secrets in diff
- importer CLI dry-run bootstrap works

Includes JSON HTTPException handler under /api/* and parse-safe static 404
via abort. JSON stores remain runtime-authoritative; production operation
still gated behind operator approval.
2026-08-16 07:42:32 +07:00

198 lines
7.5 KiB
Python

"""JWT auth decorators + role guards + shared API helpers."""
from __future__ import annotations
import functools
import json
from typing import Any, Callable
from flask import current_app, g, has_request_context, jsonify, request
from werkzeug.exceptions import HTTPException, RequestEntityTooLarge
from ..auth.users import AuthError, is_valid_tenant_id
from ..config import Config
from ..storage.store import StoreError
class ApiError(Exception):
def __init__(self, message: str, status: int = 400):
super().__init__(message)
self.message = message
self.status = status
_MISSING = object()
def _get_store():
from flask import current_app
return current_app.extensions["user_store"]
def current_user() -> dict[str, Any]:
return g.user
def request_json_object(*, allow_empty: bool = False) -> dict[str, Any]:
"""Parse a JSON object request body and reject other JSON shapes safely."""
data = request.get_json(silent=True)
if data is None and allow_empty:
return {}
if not isinstance(data, dict):
raise ApiError("invalid request", 400)
return data
def current_org_id() -> str:
value = getattr(g, "org_id", _MISSING)
if value is _MISSING:
user = getattr(g, "user", None)
value = user.get("org_id") if isinstance(user, dict) else None
return value if is_valid_tenant_id(value) else ""
def assert_tenant(org_id: str | None) -> None:
"""Raise 403 if the object's org does not belong to the actor's tenant.
super_admin is the platform operator and is exempt (can see all orgs)."""
if (g.user or {}).get("role") == "super_admin":
return
if not is_valid_tenant_id(org_id) or org_id != current_org_id():
raise ApiError("permission denied", 403)
def require_auth(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
header = request.headers.get("Authorization", "")
scheme, _, token = header.partition(" ")
if scheme.lower() != "bearer" or not token:
raise ApiError("authentication required", 401)
try:
payload = _get_store().decode_token(token)
except AuthError:
raise ApiError("invalid or expired token", 401)
if not isinstance(payload, dict):
raise ApiError("invalid token claims", 401)
subject = payload.get("sub")
token_org_id = payload.get("org_id")
if not isinstance(subject, str) or not subject:
raise ApiError("invalid token claims", 401)
if not is_valid_tenant_id(token_org_id):
raise ApiError("invalid tenant context", 401)
try:
user = _get_store().get_user_or_none(subject)
except (AuthError, OSError, StoreError, TypeError, UnicodeError, ValueError):
raise ApiError("account state unavailable", 503)
if user is None:
raise ApiError("account is inactive", 401)
if not isinstance(user, dict):
raise ApiError("account state unavailable", 503)
try:
_get_store().validate_persisted_identity(user)
except AuthError:
raise ApiError("invalid account state", 401)
if user.get("active") is not True:
raise ApiError("account is inactive", 401)
if (
not isinstance(user.get("id"), str)
or user.get("id") != subject
or user.get("username") != subject
or subject != subject.strip().lower()
or not isinstance(user.get("role"), str)
or user.get("role") not in Config.ROLES
or payload.get("role") != user.get("role")
):
raise ApiError("invalid account state", 401)
if not isinstance(user.get("must_setup"), bool):
raise ApiError("invalid account state", 401)
token_auth_version = payload.get("auth_version", 0)
user_auth_version = user.get("auth_version", 0)
if (
isinstance(token_auth_version, bool)
or not isinstance(token_auth_version, int)
or token_auth_version < 0
or isinstance(user_auth_version, bool)
or not isinstance(user_auth_version, int)
or user_auth_version < 0
or token_auth_version != user_auth_version
):
raise ApiError("invalid or revoked token", 401)
user_org_id = user.get("org_id")
if not is_valid_tenant_id(user_org_id) or token_org_id != user_org_id:
raise ApiError("invalid tenant context", 401)
try:
org = _get_store().get_org_or_none(user_org_id)
except (AuthError, OSError, StoreError, TypeError, UnicodeError, ValueError):
raise ApiError("account state unavailable", 503)
if org is None:
raise ApiError("organization is inactive or missing", 401)
if not isinstance(org, dict):
raise ApiError("account state unavailable", 503)
if org.get("active") is not True:
raise ApiError("organization is inactive or missing", 401)
g.user = user
g.token_payload = payload
# Tenant context: every request carries the actor's org id so downstream guards
# can enforce per-org isolation without re-reading the user each time.
g.org_id = user_org_id
if user["must_setup"] is True and request.endpoint not in {"auth.me", "auth.setup"}:
raise ApiError("account setup required", 403)
return fn(*args, **kwargs)
return wrapper
def require_roles(*roles: str) -> Callable:
def deco(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
role = g.user.get("role")
# super_admin passes any role gate
allowed = {"super_admin", *roles}
if not isinstance(role, str) or role not in allowed:
raise ApiError("permission denied", 403)
return fn(*args, **kwargs)
return wrapper
return deco
def api_error_handler(err: ApiError):
return jsonify({"error": err.message}), err.status
def internal_error(message: str, exc: BaseException, status: int = 500) -> ApiError:
"""Log only the exception type; never expose exception text to clients."""
current_app.logger.error(
"%s (error_type=%s)", message, type(exc).__name__
)
return ApiError(message, status)
def request_too_large_handler(_err: RequestEntityTooLarge):
return jsonify({"error": "uploaded file too large"}), 413
def unhandled_error_handler(err: Exception):
if isinstance(err, HTTPException):
response = err.get_response()
if has_request_context() and (request.path == "/api" or request.path.startswith("/api/")):
description = err.description if isinstance(err.description, str) else err.name
response.data = json.dumps({"error": description}, ensure_ascii=False).encode("utf-8")
response.content_type = "application/json"
return response
current_app.logger.error(
"unhandled request error (error_type=%s)", type(err).__name__
)
return jsonify({"error": "internal server error"}), 500
def register_error_handlers(app) -> None:
app.register_error_handler(ApiError, api_error_handler)
app.register_error_handler(RequestEntityTooLarge, request_too_large_handler)
app.register_error_handler(HTTPException, unhandled_error_handler)
app.register_error_handler(ValueError, lambda _e: (jsonify({"error": "invalid request"}), 400))
app.register_error_handler(Exception, unhandled_error_handler)