Close the gaps between the documented contract (API-STANDARD, TENANCY, ENTITLEMENTS) and the implementation, and remove committed build artifacts. Security: - verify + process inbound webhooks (HMAC/handler verify, size limit, timestamp tolerance, idempotency via InboundEvent.external_id) - real 2FA login flow (pre-auth challenge; tokens only after verify/recovery) - wire HybridPermission into security/audit views; add API-key rotate and users/<id>/permissions|roles endpoints - tenant-scoped throttling on by default; webhook replay protection - verify MercadoPago webhook signatures - login brute-force guard, configurable password policy, real ALTCHA PoW Correctness: - apply verified billing webhooks idempotently (subscription/entitlement/ invoice/PaymentTransaction); scheduled payment lifecycle jobs - capture audit update diffs automatically; add audit retention purge - working notification retries, per-channel rate limits, log retention - pluggable virus scanner, upload-size limit, pipeline toggle - feature rollout %/environment targeting; settings-driven registrations - workflow guards (instance cap, route depth, self-assignment, clone on re-entry) - wire every previously-dead INFRASYNTH_* setting; drop truly dead ones Delivery: - README + CHANGELOG; CI format check + coverage gate - keep test media out of the tree; untrack .coverage, __pycache__, egg-info, docs/ and invoice artifacts
117 lines
4.1 KiB
Python
117 lines
4.1 KiB
Python
"""Envelope-aware DRF exception handler and error helpers (``API-STANDARD.md`` §4, §5)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from django.http import JsonResponse
|
|
from rest_framework import status as http_status
|
|
from rest_framework.exceptions import (
|
|
APIException,
|
|
AuthenticationFailed,
|
|
NotAuthenticated,
|
|
NotFound,
|
|
PermissionDenied,
|
|
Throttled,
|
|
ValidationError,
|
|
)
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import exception_handler as drf_exception_handler
|
|
|
|
from infrasynth.shared.exceptions import AppError
|
|
|
|
__all__ = ["envelope_exception_handler", "error_response", "envelope_body"]
|
|
|
|
|
|
def _flatten_details(detail: Any) -> list[dict[str, Any]]:
|
|
"""Turns a DRF ``detail`` (dict/list/str) into the standard details array."""
|
|
if isinstance(detail, dict):
|
|
flattened: list[dict[str, Any]] = []
|
|
for field, issue in detail.items():
|
|
if isinstance(issue, (list, tuple)):
|
|
for entry in issue:
|
|
flattened.append({"field": str(field), "issue": str(entry)})
|
|
else:
|
|
flattened.append({"field": str(field), "issue": str(issue)})
|
|
return flattened
|
|
if isinstance(detail, (list, tuple)):
|
|
return [{"issue": str(entry)} for entry in detail]
|
|
if detail is None:
|
|
return []
|
|
return [{"issue": str(detail)}]
|
|
|
|
|
|
def _map_exception(exc: APIException) -> tuple[str, str]:
|
|
if isinstance(exc, ValidationError):
|
|
return "VALIDATION_ERROR", "The request payload is invalid."
|
|
if isinstance(exc, (NotAuthenticated, AuthenticationFailed)):
|
|
return "AUTH_UNAUTHENTICATED", "Authentication credentials were not provided or are invalid."
|
|
if isinstance(exc, PermissionDenied):
|
|
return "AUTH_FORBIDDEN", "You do not have permission to perform this action."
|
|
if isinstance(exc, NotFound):
|
|
return "NOT_FOUND", "The requested resource was not found."
|
|
if isinstance(exc, Throttled):
|
|
return "RATE_LIMIT_EXCEEDED", "Too many requests."
|
|
return "SERVER_ERROR", "An unexpected error occurred."
|
|
|
|
|
|
def envelope_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None:
|
|
"""DRF ``EXCEPTION_HANDLER`` producing ``{code, message, details}`` bodies.
|
|
|
|
The envelope itself is added by :class:`EnvelopeJSONRenderer` based on the
|
|
HTTP status. Unexpected (non-``APIException``, non-``AppError``) exceptions
|
|
return ``None`` so Django still surfaces them loudly instead of masking a
|
|
bug behind a generic 500.
|
|
"""
|
|
if isinstance(exc, AppError):
|
|
return Response(exc.to_dict(), status=exc.status)
|
|
|
|
response = drf_exception_handler(exc, context)
|
|
if response is None:
|
|
return None
|
|
|
|
code, message = _map_exception(exc) if isinstance(exc, APIException) else ("SERVER_ERROR", str(exc))
|
|
detail = getattr(exc, "detail", None)
|
|
response.data = {
|
|
"code": code,
|
|
"message": message,
|
|
"details": _flatten_details(detail),
|
|
}
|
|
return response
|
|
|
|
|
|
def envelope_body(
|
|
*,
|
|
success: bool,
|
|
data: Any = None,
|
|
error: dict[str, Any] | None = None,
|
|
meta: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Builds an envelope dict for middleware that bypasses DRF views."""
|
|
return {"success": success, "data": data, "error": error, "meta": meta or {}}
|
|
|
|
|
|
def error_response(
|
|
code: str,
|
|
message: str,
|
|
*,
|
|
status_code: int = http_status.HTTP_403_FORBIDDEN,
|
|
details: list[dict[str, Any]] | None = None,
|
|
request: Any = None,
|
|
) -> JsonResponse:
|
|
"""Minimal envelope error for middleware (no DRF renderer in the chain)."""
|
|
from django.utils import timezone
|
|
|
|
body = envelope_body(
|
|
success=False,
|
|
error={"code": code, "message": message, "details": details or []},
|
|
meta={
|
|
"requestId": getattr(request, "request_id", "") if request else "",
|
|
"timestamp": timezone.now().isoformat().replace("+00:00", "Z"),
|
|
},
|
|
)
|
|
response = JsonResponse(body, status=status_code)
|
|
request_id = getattr(request, "request_id", "") if request else ""
|
|
if request_id:
|
|
response["X-Request-Id"] = request_id
|
|
return response
|