Files
sales-trainer/backend/app/api/helpers.py
Macky 3c22d88bcd feat: demo SaaS + training flow security hardening (8/8 review gate passed)
- Demo accounts: super_admin-only provisioning into isolated DEMO_ORG_ID tenant,
  30-day UTC trial on first login, revocable, one-time credential delivery via
  optional SES/webhook (never persisted). Adds boto3 dependency.
- Analytics/report/export/privacy: shared bounded scan budget across users/groups/
  sessions, tenant-consistent session/user/group joins, scalar-only CSV export
  (no nested persisted-value stringification).
- Ownership/tenant isolation: canonical owner-tenant predicate for list/read/chat;
  client sees is_owned only, never owner_user_id.
- Lifecycle/races: status transition validation, analyzing is an in-progress gate
  (no duplicate reanalysis), structured-ready publication, stale-variant revalidation.
- Auth/setup/consent/JWT/OAuth/config: fail-closed consent, bounded JWT lifetime,
  provider-subject atomic OAuth identity, repeated-secret rejection, strict Persona
  trait validation.
- Chat/session/privacy: pre-seller opener redaction, corrupt-session recovery,
  role-aware completed-chat dashboard routing.
- Frontend: Training→product→personas→practice flow, demo/role/demo guards,
  is_owned-based ownership display, 320×568 and 500×768 responsive E2E.
- 8 independent exact-five-key review scopes passed; backend 509, frontend 26,
  production build 1775 modules, isolated E2E 15.
2026-08-25 06:39:06 +07:00

218 lines
8.3 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,
demo_trial_is_active,
is_valid_terms_timestamp,
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 user.get("role") == "demo" and not demo_trial_is_active(user):
# Check the persisted trial on every request so a JWT issued before
# expiry cannot keep a demo session alive.
raise ApiError("demo trial expired", 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
must_setup = user.get("must_setup")
accepted_terms = user.get("accepted_terms")
if (
not isinstance(must_setup, bool)
or not isinstance(accepted_terms, bool)
or (accepted_terms and not is_valid_terms_timestamp(user.get("accepted_terms_at")))
or (not accepted_terms and user.get("accepted_terms_at") is not None)
or (must_setup is False and accepted_terms is not True)
):
raise ApiError("invalid or revoked token", 401)
setup_required = must_setup or not accepted_terms
if setup_required 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)