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.
198 lines
7.5 KiB
Python
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)
|