Zum Hauptinhalt springen

PLAN: Webhooks booking-side — receber eventos dos SaaS

Contexto

Estratégia P2 aprovada: platform é hub billing, SaaS precisam de notificar o platform quando eventos ocorrem (tenant status change, usage reports, provisioning failures). Actualmente o platform só tem 1 callback endpoint para provisioning success — sem cobertura para falhas, status changes ou usage.

Regra arquitectural: platform é publisher NATS only (nunca subscriber). Eventos dos SaaS chegam via HTTP webhooks autenticados.

Objectivo

Criar endpoint genérico POST /api/webhooks/saas-events que recebe eventos de qualquer SaaS (booking, virtual-stores) e despacha para handlers por event_type. Autenticação via HMAC shared secret (por produto).

Ficheiros afectados — 4 ficheiros

#FicheiroMudança
1backend/src/modules/provisioning/schemas.pyCRIAR — schemas Pydantic para SaaS events
2backend/src/modules/provisioning/saas_webhook_handler.pyCRIAR — handler class com dispatch por event_type
3backend/src/modules/provisioning/routes.pyEDITAR — adicionar POST /webhooks/saas-events
4backend/src/core/config.pyEDITAR — adicionar saas_webhook_secrets (dict por produto)

1. Schemas — provisioning/schemas.py (CRIAR)

from datetime import datetime
from pydantic import BaseModel, Field

class SaaSEventPayload(BaseModel):
event_type: str = Field(..., description="tenant.status_changed | usage.report | provisioning.confirmed | provisioning.failed")
source: str = Field(..., description="booking-system | virtual-stores-system")
tenant_id: int | None = None
service_id: int | None = None
timestamp: datetime
data: dict = Field(default_factory=dict)

class TenantStatusChangedData(BaseModel):
new_status: str # active | suspended | terminated
reason: str | None = None
platform_service_id: int | None = None

class UsageReportData(BaseModel):
metric: str # bookings_count | active_users | storage_mb
value: float
period_start: datetime
period_end: datetime
subscription_id: int | None = None

class ProvisioningResultData(BaseModel):
external_id: str | None = None
external_url: str | None = None
error_message: str | None = None

2. Handler — provisioning/saas_webhook_handler.py (CRIAR)

class SaaSWebhookHandler:
def __init__(self, db: Session):
self.db = db

async def dispatch(self, event: SaaSEventPayload):
handlers = {
"tenant.status_changed": self._handle_status_changed,
"usage.report": self._handle_usage_report,
"provisioning.confirmed": self._handle_provisioning_confirmed,
"provisioning.failed": self._handle_provisioning_failed,
}
handler = handlers.get(event.event_type)
if not handler:
logger.warning(f"Unknown SaaS event type: {event.event_type}")
return {"status": "ignored", "reason": "unknown_event_type"}
return await handler(event)

Handlers:

  • _handle_status_changed: query ClientService by service_id or tenant_id+source, update status, log + notify sysadmin
  • _handle_usage_report: criar UsageRecord na BD para billing metered (usa modelo existente da migração Alembic)
  • _handle_provisioning_confirmed: reutilizar lógica do callback existente (set external_id, status=active)
  • _handle_provisioning_failed: marcar service como failed, ProvisioningRecord como failed, notificar sysadmin

3. Route — provisioning/routes.py (EDITAR)

Adicionar endpoint:

@router.post("/webhooks/saas-events")
async def saas_events_webhook(request: Request, db: Session = Depends(get_db)):
body = await request.body()
signature = request.headers.get("X-Webhook-Signature", "")
source = request.headers.get("X-Webhook-Source", "")

secret = settings.saas_webhook_secrets.get(source, "")
if not secret or not verify_hmac(body, signature, secret):
raise HTTPException(status_code=403, detail="Invalid signature")

event = SaaSEventPayload.model_validate_json(body)
handler = SaaSWebhookHandler(db)
result = await handler.dispatch(event)
return result

Helper verify_hmac:

import hashlib, hmac
def verify_hmac(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)

4. Config — core/config.py (EDITAR)

Após provisioning_callback_secret adicionar:

# SaaS webhook secrets (HMAC per product)
saas_webhook_secret_booking: str = ""
saas_webhook_secret_stores: str = ""

@property
def saas_webhook_secrets(self) -> dict[str, str]:
return {
"booking-system": self.saas_webhook_secret_booking,
"virtual-stores-system": self.saas_webhook_secret_stores,
}

Validação

  1. python -m py_compile nos 4 ficheiros
  2. cd backend && python -m pytest src/tests/test_provisioning.py -v
  3. ruff check nos ficheiros novos
  4. Pipeline green

Fora de scope

  • SDK schemas para SaaS→platform (SDK é read-only, requer coordenação VPE)
  • NATS subscriber no platform (regra: publisher only)
  • Implementação do lado booking/stores (responsabilidade dos outros Tech Leads)
  • Testes E2E (endpoint interno, não há UI)