Files
konstruct/packages/gateway/gateway/main.py
Adolfo Delorenzo d59f85cd87 feat(04-rbac-01): RBAC guards + invite token + email + invitation API
- rbac.py: PortalCaller dataclass + get_portal_caller dependency (header-based)
- rbac.py: require_platform_admin (403 for non-platform_admin)
- rbac.py: require_tenant_admin (platform_admin bypasses; customer_admin
  checks UserTenantRole; operator always rejected)
- rbac.py: require_tenant_member (platform_admin bypasses; all roles
  checked against UserTenantRole)
- invite_token.py: generate_invite_token (HMAC-SHA256, base64url, 48h TTL)
- invite_token.py: validate_invite_token (timing-safe compare_digest, TTL check)
- invite_token.py: token_to_hash (SHA-256 for DB storage)
- email.py: send_invite_email (sync smtplib, skips if smtp_host empty)
- invitations.py: POST /api/portal/invitations (create, requires tenant admin)
- invitations.py: POST /api/portal/invitations/accept (accept invitation)
- invitations.py: POST /api/portal/invitations/{id}/resend (regenerate token)
- invitations.py: GET /api/portal/invitations (list pending)
- portal.py: AuthVerifyResponse now returns role+tenant_ids+active_tenant_id
- portal.py: auth/register gated behind require_platform_admin
- tasks.py: send_invite_email_task Celery task (fire-and-forget)
- gateway/main.py: invitations_router mounted
2026-03-24 13:52:45 -06:00

171 lines
6.0 KiB
Python

"""
Channel Gateway — FastAPI application.
Mounts the slack-bolt AsyncApp as a sub-application at /slack/events.
Registers the WhatsApp webhook router at /whatsapp/webhook.
Serves all Phase 3 portal API routes under /api/portal/* and /api/webhooks/*.
Port: 8001
Endpoints:
POST /slack/events — Slack Events API webhook (handled by slack-bolt)
GET /whatsapp/webhook — WhatsApp hub challenge verification
POST /whatsapp/webhook — WhatsApp inbound message webhook
GET /api/portal/* — Portal management API (tenants, agents, billing, etc.)
GET /api/portal/billing/* — Stripe billing endpoints
GET /api/portal/channels/* — Channel connection endpoints (Slack OAuth, WhatsApp)
GET /api/portal/tenants/{id}/llm-keys — BYO LLM key management
GET /api/portal/usage/* — Usage and cost analytics
POST /api/webhooks/* — Stripe webhook receiver
GET /health — Health check
Startup sequence:
1. Create Redis connection
2. Create slack-bolt AsyncApp (signing_secret=...)
3. Register Slack event handlers
4. Mount slack-bolt request handler at /slack/events
5. Include WhatsApp router
6. Include Phase 3 portal API routers
7. Expose /health
"""
from __future__ import annotations
import logging
from fastapi import FastAPI, Request, Response
from redis.asyncio import Redis
from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler
from slack_bolt.async_app import AsyncApp
from gateway.channels.slack import register_slack_handlers
from gateway.channels.whatsapp import whatsapp_router
from shared.api import (
billing_router,
channels_router,
invitations_router,
llm_keys_router,
portal_router,
usage_router,
webhook_router,
)
from shared.config import settings
from shared.db import async_session_factory
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
app = FastAPI(
title="Konstruct Channel Gateway",
description="Unified ingress for all messaging platforms",
version="0.1.0",
)
# ---------------------------------------------------------------------------
# CORS — allow portal origin to call gateway API
# ---------------------------------------------------------------------------
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://100.64.0.10:3000",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# Slack bolt app — initialized at module import time.
# signing_secret="" is safe for local dev/testing; set via env in production.
# ---------------------------------------------------------------------------
slack_app: AsyncApp | None = None
if settings.slack_bot_token and settings.slack_signing_secret:
slack_app = AsyncApp(
token=settings.slack_bot_token,
signing_secret=settings.slack_signing_secret,
)
else:
import logging
logging.getLogger(__name__).warning(
"SLACK_BOT_TOKEN or SLACK_SIGNING_SECRET not set — Slack adapter disabled"
)
# Async Redis client — shared across all request handlers
_redis: Redis | None = None # type: ignore[type-arg]
def _get_redis() -> Redis: # type: ignore[type-arg]
"""Return the module-level Redis client, creating it if necessary."""
global _redis
if _redis is None:
_redis = Redis.from_url(settings.redis_url, decode_responses=True)
return _redis
# ---------------------------------------------------------------------------
# Register Slack event handlers
# ---------------------------------------------------------------------------
slack_handler: AsyncSlackRequestHandler | None = None
if slack_app is not None:
register_slack_handlers(
slack_app=slack_app,
redis=_get_redis(),
get_session=async_session_factory,
)
slack_handler = AsyncSlackRequestHandler(slack_app)
# ---------------------------------------------------------------------------
# Register channel routers
# ---------------------------------------------------------------------------
app.include_router(whatsapp_router)
# ---------------------------------------------------------------------------
# Register Phase 3 portal API routers
# ---------------------------------------------------------------------------
app.include_router(portal_router)
app.include_router(billing_router)
app.include_router(channels_router)
app.include_router(llm_keys_router)
app.include_router(usage_router)
app.include_router(webhook_router)
# ---------------------------------------------------------------------------
# Register Phase 4 RBAC routers
# ---------------------------------------------------------------------------
app.include_router(invitations_router)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.post("/slack/events")
async def slack_events(request: Request) -> Response:
"""
Slack Events API webhook endpoint.
slack-bolt's AsyncSlackRequestHandler handles:
- Slack signature verification (X-Slack-Signature)
- URL verification challenge (type=url_verification)
- Event routing to registered handlers
CRITICAL: This endpoint MUST return HTTP 200 within 3 seconds.
All LLM/heavy work is dispatched to Celery inside the event handlers.
"""
if slack_handler is None:
return Response(content='{"error":"Slack not configured"}', status_code=503, media_type="application/json")
return await slack_handler.handle(request)
@app.get("/health")
async def health() -> dict[str, str]:
"""Health check endpoint."""
return {"status": "ok", "service": "gateway"}