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
197 lines
7 KiB
Python
197 lines
7 KiB
Python
import logging
|
|
from datetime import timedelta
|
|
|
|
import requests
|
|
from celery import shared_task
|
|
from django.template import Context, Template
|
|
from django.utils import timezone
|
|
|
|
from infrasynth.shared.settings_utils import get_setting
|
|
|
|
from .models import OutboundDelivery, OutboundSubscription
|
|
from .signals import outbound_delivery_failed, outbound_delivery_succeeded
|
|
from .signature import sign_payload
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@shared_task(
|
|
name="infrasynth.webhooks.deliver_webhook",
|
|
bind=True,
|
|
max_retries=5,
|
|
default_retry_delay=60,
|
|
)
|
|
def deliver_webhook(self, subscription_id, event_name, payload, payload_template, tenant_id=None):
|
|
"""Delivers an outbound webhook with HMAC signature and retry/backoff."""
|
|
from infrasynth.tenancy.context import tenant_context
|
|
from infrasynth.tenancy.models import Tenant
|
|
|
|
try:
|
|
subscription = OutboundSubscription.all_objects.select_related("endpoint").get(pk=subscription_id)
|
|
except OutboundSubscription.DoesNotExist:
|
|
logger.warning("Subscription %s not found", subscription_id)
|
|
return None
|
|
|
|
tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else subscription.tenant
|
|
with tenant_context(tenant):
|
|
return _deliver_webhook_inner(self, subscription, event_name, payload, payload_template, tenant)
|
|
|
|
|
|
def _deliver_webhook_inner(self, subscription, event_name, payload, payload_template, tenant):
|
|
endpoint = subscription.endpoint
|
|
body = _build_payload(payload, payload_template)
|
|
|
|
delivery = OutboundDelivery.all_objects.create(
|
|
tenant=tenant or subscription.tenant,
|
|
subscription=subscription,
|
|
payload=body,
|
|
status=OutboundDelivery.Status.RETRYING,
|
|
attempt=self.request.retries + 1,
|
|
)
|
|
|
|
headers = {"Content-Type": "application/json"}
|
|
headers.update(endpoint.headers or {})
|
|
headers[get_setting("INFRASYNTH_WEBHOOKS", "SIGNATURE_HEADER", "X-Webhook-Signature")] = sign_payload(
|
|
endpoint.secret, body
|
|
)
|
|
|
|
timeout = endpoint.timeout_seconds or get_setting("INFRASYNTH_WEBHOOKS", "DEFAULT_TIMEOUT_SECONDS", 10)
|
|
|
|
try:
|
|
response = requests.post(
|
|
endpoint.url,
|
|
data=body,
|
|
headers=headers,
|
|
timeout=timeout,
|
|
)
|
|
except requests.RequestException as exc:
|
|
return _handle_failure(self, delivery, subscription, endpoint, str(exc))
|
|
|
|
delivery.response_status = response.status_code
|
|
delivery.response_body = response.text[:4000]
|
|
delivery.completed_at = timezone.now()
|
|
|
|
if 200 <= response.status_code < 300:
|
|
delivery.status = OutboundDelivery.Status.SUCCESS
|
|
delivery.save()
|
|
outbound_delivery_succeeded.send(
|
|
sender=OutboundDelivery,
|
|
delivery_id=delivery.id,
|
|
event_name=event_name,
|
|
status_code=response.status_code,
|
|
)
|
|
logger.info(
|
|
"Webhook delivered: %s -> %s (%s)",
|
|
event_name,
|
|
endpoint.url,
|
|
response.status_code,
|
|
)
|
|
return delivery.id
|
|
|
|
delivery.save()
|
|
return _handle_failure(
|
|
self,
|
|
delivery,
|
|
subscription,
|
|
endpoint,
|
|
f"HTTP {response.status_code}: {response.text[:500]}",
|
|
)
|
|
|
|
|
|
def _build_payload(payload: dict, payload_template: str) -> str:
|
|
import json
|
|
|
|
if payload_template:
|
|
try:
|
|
template = Template(payload_template)
|
|
return template.render(Context({"event": payload, "payload": payload}))
|
|
except Exception: # noqa: BLE001
|
|
logger.exception("Failed to render webhook payload template")
|
|
return json.dumps(payload, default=str)
|
|
|
|
|
|
def _handle_failure(self, delivery, subscription, endpoint, error: str):
|
|
max_retries = int(
|
|
(endpoint.retry_policy or {}).get("max_retries", get_setting("INFRASYNTH_WEBHOOKS", "MAX_RETRIES", 5))
|
|
)
|
|
backoff = (endpoint.retry_policy or {}).get(
|
|
"backoff", get_setting("INFRASYNTH_WEBHOOKS", "RETRY_BACKOFF", "exponential")
|
|
)
|
|
initial_delay = int(get_setting("INFRASYNTH_WEBHOOKS", "RETRY_INITIAL_DELAY_SECONDS", 60))
|
|
|
|
delivery.status = OutboundDelivery.Status.RETRYING
|
|
delivery.response_body = error[:4000] if not delivery.response_body else delivery.response_body
|
|
|
|
if self.request.retries < max_retries:
|
|
countdown = initial_delay * (2**self.request.retries) if backoff == "exponential" else initial_delay
|
|
delivery.next_retry_at = timezone.now() + timedelta(seconds=countdown)
|
|
delivery.save()
|
|
logger.warning("Webhook delivery failed (attempt %s): %s", self.request.retries + 1, error)
|
|
raise self.retry(exc=Exception(error), countdown=countdown) from None
|
|
|
|
delivery.status = OutboundDelivery.Status.FAILED
|
|
delivery.completed_at = timezone.now()
|
|
delivery.save()
|
|
outbound_delivery_failed.send(
|
|
sender=OutboundDelivery,
|
|
delivery_id=delivery.id,
|
|
event_name=delivery.subscription.event_name,
|
|
error=error,
|
|
)
|
|
logger.error("Webhook delivery gave up: %s", error)
|
|
return None
|
|
|
|
|
|
@shared_task(
|
|
name="infrasynth.webhooks.process_inbound_event",
|
|
bind=True,
|
|
max_retries=3,
|
|
default_retry_delay=60,
|
|
)
|
|
def process_inbound_event(self, event_id, tenant_id=None):
|
|
"""Runs the endpoint's handler against a verified inbound event."""
|
|
from django.utils.module_loading import import_string
|
|
|
|
from infrasynth.tenancy.context import tenant_context
|
|
from infrasynth.tenancy.models import Tenant
|
|
|
|
from .inbound.handlers import HMACInboundHandler
|
|
from .models import InboundEvent
|
|
from .signals import inbound_event_processed
|
|
|
|
try:
|
|
event = InboundEvent.all_objects.select_related("endpoint").get(pk=event_id)
|
|
except InboundEvent.DoesNotExist:
|
|
logger.warning("Inbound event %s not found", event_id)
|
|
return None
|
|
|
|
tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else event.tenant
|
|
with tenant_context(tenant):
|
|
handler = None
|
|
if event.endpoint.handler:
|
|
try:
|
|
handler = import_string(event.endpoint.handler)()
|
|
except (ImportError, TypeError):
|
|
handler = None
|
|
handler = handler or HMACInboundHandler()
|
|
try:
|
|
result = handler.process(event.event_type, event.raw_payload)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("Inbound event %s processing failed", event_id)
|
|
event.error = str(exc)
|
|
event.save(update_fields=["error"])
|
|
raise self.retry(exc=exc) from exc
|
|
|
|
event.result = result if isinstance(result, dict) else {"result": result}
|
|
event.is_processed = True
|
|
event.error = ""
|
|
event.processed_at = timezone.now()
|
|
event.save(update_fields=["result", "is_processed", "error", "processed_at"])
|
|
inbound_event_processed.send(
|
|
sender=InboundEvent,
|
|
tenant_id=str(event.tenant_id) if event.tenant_id else None,
|
|
event_id=event.id,
|
|
event_type=event.event_type,
|
|
result=event.result,
|
|
)
|
|
return event.id
|