526 lines
18 KiB
Python
526 lines
18 KiB
Python
"""
|
|
MVP Platform Tenants Service - FastAPI Application
|
|
Handles tenant management, signup approvals, and multi-tenant infrastructure.
|
|
"""
|
|
|
|
from fastapi import FastAPI, HTTPException, Depends, Header
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
import asyncpg
|
|
import os
|
|
import json
|
|
import httpx
|
|
from typing import Optional, List, Dict
|
|
from pydantic import BaseModel
|
|
from datetime import datetime
|
|
import logging
|
|
from jose import jwt, jwk
|
|
from jose.exceptions import JWTError, ExpiredSignatureError
|
|
|
|
# Configure logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = FastAPI(
|
|
title="MVP Platform Tenants Service",
|
|
description="Multi-tenant management and signup approval service",
|
|
version="1.0.0"
|
|
)
|
|
|
|
# CORS middleware
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # Configure appropriately for production
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Auth0 configuration
|
|
AUTH0_DOMAIN = os.getenv("AUTH0_DOMAIN")
|
|
AUTH0_AUDIENCE = os.getenv("AUTH0_AUDIENCE", "https://api.motovaultpro.com")
|
|
|
|
# Cache for JWKS keys (in production, use Redis)
|
|
_jwks_cache = {}
|
|
_jwks_cache_expiry = 0
|
|
|
|
# Database connection
|
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://platform_user:platform_pass@platform-postgres:5432/platform")
|
|
|
|
# Helper function to parse JSON settings
|
|
def parse_json_field(value):
|
|
if isinstance(value, str):
|
|
try:
|
|
return json.loads(value)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
return value or {}
|
|
|
|
# Models
|
|
class TenantCreate(BaseModel):
|
|
id: str
|
|
name: str
|
|
subdomain: str
|
|
admin_user_id: Optional[str] = None
|
|
settings: dict = {}
|
|
|
|
class TenantResponse(BaseModel):
|
|
id: str
|
|
name: str
|
|
subdomain: str
|
|
status: str
|
|
admin_user_id: Optional[str]
|
|
settings: dict
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
@classmethod
|
|
def from_db_row(cls, row):
|
|
data = dict(row)
|
|
data['settings'] = parse_json_field(data.get('settings'))
|
|
return cls(**data)
|
|
|
|
class SignupRequest(BaseModel):
|
|
user_email: str
|
|
user_auth0_id: Optional[str] = None
|
|
|
|
class SignupResponse(BaseModel):
|
|
id: int
|
|
tenant_id: str
|
|
user_email: str
|
|
user_auth0_id: Optional[str]
|
|
status: str
|
|
requested_at: datetime
|
|
approved_by: Optional[str] = None
|
|
approved_at: Optional[datetime] = None
|
|
rejected_at: Optional[datetime] = None
|
|
rejection_reason: Optional[str] = None
|
|
|
|
class SignupApproval(BaseModel):
|
|
reason: Optional[str] = None
|
|
|
|
# JWT Authentication functions
|
|
async def get_jwks() -> Dict:
|
|
"""Fetch JWKS from Auth0 with caching"""
|
|
global _jwks_cache, _jwks_cache_expiry
|
|
import time
|
|
|
|
current_time = time.time()
|
|
|
|
# Return cached JWKS if not expired (cache for 1 hour)
|
|
if _jwks_cache and current_time < _jwks_cache_expiry:
|
|
return _jwks_cache
|
|
|
|
if not AUTH0_DOMAIN:
|
|
raise HTTPException(status_code=500, detail="Auth0 configuration missing")
|
|
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.get(f"https://{AUTH0_DOMAIN}/.well-known/jwks.json")
|
|
response.raise_for_status()
|
|
jwks = response.json()
|
|
|
|
# Cache the JWKS for 1 hour
|
|
_jwks_cache = jwks
|
|
_jwks_cache_expiry = current_time + 3600
|
|
|
|
return jwks
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to fetch JWKS: {str(e)}")
|
|
|
|
async def get_signing_key(kid: str) -> str:
|
|
"""Get signing key for the given kid"""
|
|
jwks = await get_jwks()
|
|
|
|
for key in jwks.get("keys", []):
|
|
if key.get("kid") == kid:
|
|
return jwk.construct(key).key
|
|
|
|
raise HTTPException(status_code=401, detail="Unable to find appropriate key")
|
|
|
|
async def verify_jwt(token: str) -> Dict:
|
|
"""Verify and decode JWT token"""
|
|
if not AUTH0_DOMAIN or not AUTH0_AUDIENCE:
|
|
raise HTTPException(status_code=500, detail="Auth0 configuration missing")
|
|
|
|
try:
|
|
# Get the kid from token header
|
|
unverified_header = jwt.get_unverified_header(token)
|
|
kid = unverified_header.get("kid")
|
|
|
|
if not kid:
|
|
raise HTTPException(status_code=401, detail="Token header missing kid")
|
|
|
|
# Get the signing key
|
|
signing_key = await get_signing_key(kid)
|
|
|
|
# Verify and decode the token
|
|
payload = jwt.decode(
|
|
token,
|
|
signing_key,
|
|
algorithms=["RS256"],
|
|
audience=AUTH0_AUDIENCE,
|
|
issuer=f"https://{AUTH0_DOMAIN}/"
|
|
)
|
|
|
|
return payload
|
|
|
|
except ExpiredSignatureError:
|
|
raise HTTPException(status_code=401, detail="Token has expired")
|
|
except JWTError as e:
|
|
raise HTTPException(status_code=401, detail=f"Invalid token: {str(e)}")
|
|
except Exception as e:
|
|
raise HTTPException(status_code=401, detail=f"Token validation failed: {str(e)}")
|
|
|
|
# Mock authentication for development/testing
|
|
async def mock_auth_user(authorization: str) -> Dict:
|
|
"""Mock authentication for testing purposes"""
|
|
if not authorization or not authorization.startswith("Bearer "):
|
|
raise HTTPException(status_code=401, detail="Authorization header required")
|
|
|
|
token = authorization.split(" ")[1]
|
|
|
|
if token == "admin-token":
|
|
return {
|
|
"sub": "admin-user",
|
|
"email": "admin@motovaultpro.com",
|
|
"https://motovaultpro.com/tenant_id": "admin",
|
|
"https://motovaultpro.com/signup_status": "approved"
|
|
}
|
|
elif token.startswith("tenant-"):
|
|
tenant_id = token.replace("tenant-", "", 1).replace("-token", "")
|
|
return {
|
|
"sub": f"{tenant_id}-admin",
|
|
"email": f"admin@{tenant_id}.com",
|
|
"https://motovaultpro.com/tenant_id": tenant_id,
|
|
"https://motovaultpro.com/signup_status": "approved"
|
|
}
|
|
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
|
|
async def get_current_user(authorization: str = Header(None)):
|
|
"""Extract and validate JWT from Authorization header"""
|
|
if not authorization:
|
|
raise HTTPException(status_code=401, detail="Authorization header required")
|
|
|
|
try:
|
|
scheme, token = authorization.split(" ", 1)
|
|
if scheme.lower() != "bearer":
|
|
raise HTTPException(status_code=401, detail="Invalid authentication scheme")
|
|
|
|
# Try real JWT validation first, fallback to mock for development
|
|
try:
|
|
if AUTH0_DOMAIN and AUTH0_AUDIENCE:
|
|
payload = await verify_jwt(token)
|
|
else:
|
|
payload = await mock_auth_user(authorization)
|
|
except HTTPException:
|
|
# Fallback to mock authentication for development
|
|
payload = await mock_auth_user(authorization)
|
|
|
|
# Extract tenant info from JWT claims
|
|
tenant_id = payload.get("https://motovaultpro.com/tenant_id", "admin")
|
|
user_id = payload.get("sub", "")
|
|
email = payload.get("email", "")
|
|
|
|
return {
|
|
"sub": user_id,
|
|
"tenant_id": tenant_id,
|
|
"email": email,
|
|
"payload": payload
|
|
}
|
|
|
|
except ValueError:
|
|
raise HTTPException(status_code=401, detail="Invalid authorization header format")
|
|
|
|
async def get_admin_user(current_user: dict = Depends(get_current_user)):
|
|
if current_user.get("tenant_id") != "admin":
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
return current_user
|
|
|
|
async def get_tenant_admin(current_user: dict = Depends(get_current_user)):
|
|
if not current_user.get("tenant_id"):
|
|
raise HTTPException(status_code=401, detail="Tenant authentication required")
|
|
return current_user
|
|
|
|
# Health check
|
|
@app.get("/health")
|
|
async def health_check():
|
|
try:
|
|
conn = await asyncpg.connect(DATABASE_URL)
|
|
await conn.execute("SELECT 1")
|
|
await conn.close()
|
|
return {
|
|
"status": "healthy",
|
|
"database": "connected",
|
|
"service": "mvp-platform-tenants",
|
|
"version": "1.0.0"
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Health check failed: {e}")
|
|
raise HTTPException(status_code=503, detail="Service unavailable")
|
|
|
|
# Tenant management endpoints
|
|
@app.post("/api/v1/tenants", response_model=TenantResponse)
|
|
async def create_tenant(
|
|
tenant_data: TenantCreate,
|
|
current_user: dict = Depends(get_admin_user)
|
|
):
|
|
"""Create new tenant (admin only)"""
|
|
conn = await asyncpg.connect(DATABASE_URL)
|
|
try:
|
|
# Check if tenant already exists
|
|
existing = await conn.fetchrow(
|
|
"SELECT id FROM tenants WHERE id = $1 OR subdomain = $2",
|
|
tenant_data.id, tenant_data.subdomain
|
|
)
|
|
if existing:
|
|
raise HTTPException(status_code=409, detail="Tenant ID or subdomain already exists")
|
|
|
|
# Insert new tenant
|
|
result = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO tenants (id, name, subdomain, admin_user_id, settings)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *
|
|
""",
|
|
tenant_data.id,
|
|
tenant_data.name,
|
|
tenant_data.subdomain,
|
|
tenant_data.admin_user_id,
|
|
json.dumps(tenant_data.settings)
|
|
)
|
|
|
|
return TenantResponse.from_db_row(result)
|
|
finally:
|
|
await conn.close()
|
|
|
|
@app.get("/api/v1/tenants", response_model=List[TenantResponse])
|
|
async def list_tenants(current_user: dict = Depends(get_admin_user)):
|
|
"""List all tenants (admin only)"""
|
|
conn = await asyncpg.connect(DATABASE_URL)
|
|
try:
|
|
results = await conn.fetch("SELECT * FROM tenants ORDER BY created_at DESC")
|
|
return [TenantResponse.from_db_row(row) for row in results]
|
|
finally:
|
|
await conn.close()
|
|
|
|
@app.get("/api/v1/tenants/{tenant_id}", response_model=TenantResponse)
|
|
async def get_tenant(tenant_id: str):
|
|
"""Get tenant details (public endpoint for validation)"""
|
|
conn = await asyncpg.connect(DATABASE_URL)
|
|
try:
|
|
result = await conn.fetchrow("SELECT * FROM tenants WHERE id = $1", tenant_id)
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
|
|
|
return TenantResponse.from_db_row(result)
|
|
finally:
|
|
await conn.close()
|
|
|
|
@app.put("/api/v1/tenants/{tenant_id}", response_model=TenantResponse)
|
|
async def update_tenant(
|
|
tenant_id: str,
|
|
tenant_data: TenantCreate,
|
|
current_user: dict = Depends(get_admin_user)
|
|
):
|
|
"""Update tenant settings (admin only)"""
|
|
conn = await asyncpg.connect(DATABASE_URL)
|
|
try:
|
|
result = await conn.fetchrow(
|
|
"""
|
|
UPDATE tenants
|
|
SET name = $2, admin_user_id = $3, settings = $4, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
RETURNING *
|
|
""",
|
|
tenant_id,
|
|
tenant_data.name,
|
|
tenant_data.admin_user_id,
|
|
json.dumps(tenant_data.settings)
|
|
)
|
|
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
|
|
|
return TenantResponse.from_db_row(result)
|
|
finally:
|
|
await conn.close()
|
|
|
|
# Signup management endpoints
|
|
@app.post("/api/v1/tenants/{tenant_id}/signups", response_model=SignupResponse)
|
|
async def request_signup(tenant_id: str, signup_data: SignupRequest):
|
|
"""Request signup approval for a tenant (public endpoint)"""
|
|
conn = await asyncpg.connect(DATABASE_URL)
|
|
try:
|
|
# Verify tenant exists and accepts signups
|
|
tenant = await conn.fetchrow(
|
|
"SELECT id, status FROM tenants WHERE id = $1", tenant_id
|
|
)
|
|
if not tenant:
|
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
|
if tenant['status'] != 'active':
|
|
raise HTTPException(status_code=400, detail="Tenant not accepting signups")
|
|
|
|
# Check for existing signup
|
|
existing = await conn.fetchrow(
|
|
"SELECT id FROM tenant_signups WHERE tenant_id = $1 AND user_email = $2",
|
|
tenant_id, signup_data.user_email
|
|
)
|
|
if existing:
|
|
raise HTTPException(status_code=409, detail="Signup request already exists")
|
|
|
|
# Create signup request
|
|
result = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO tenant_signups (tenant_id, user_email, user_auth0_id)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING *
|
|
""",
|
|
tenant_id,
|
|
signup_data.user_email,
|
|
signup_data.user_auth0_id
|
|
)
|
|
|
|
logger.info(f"New signup request: {signup_data.user_email} for tenant {tenant_id}")
|
|
return SignupResponse(**dict(result))
|
|
finally:
|
|
await conn.close()
|
|
|
|
@app.get("/api/v1/tenants/{tenant_id}/signups", response_model=List[SignupResponse])
|
|
async def get_tenant_signups(
|
|
tenant_id: str,
|
|
status: Optional[str] = "pending",
|
|
current_user: dict = Depends(get_tenant_admin)
|
|
):
|
|
"""List signups for a tenant (tenant admin only)"""
|
|
# Verify user has access to this tenant
|
|
if current_user.get("tenant_id") != tenant_id and current_user.get("tenant_id") != "admin":
|
|
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
|
|
|
conn = await asyncpg.connect(DATABASE_URL)
|
|
try:
|
|
query = "SELECT * FROM tenant_signups WHERE tenant_id = $1"
|
|
params = [tenant_id]
|
|
|
|
if status:
|
|
query += " AND status = $2"
|
|
params.append(status)
|
|
|
|
query += " ORDER BY requested_at DESC"
|
|
|
|
results = await conn.fetch(query, *params)
|
|
return [SignupResponse(**dict(row)) for row in results]
|
|
finally:
|
|
await conn.close()
|
|
|
|
@app.get("/api/v1/signups", response_model=List[SignupResponse])
|
|
async def get_all_signups(
|
|
status: Optional[str] = "pending",
|
|
current_user: dict = Depends(get_admin_user)
|
|
):
|
|
"""List all signups across all tenants (admin only)"""
|
|
conn = await asyncpg.connect(DATABASE_URL)
|
|
try:
|
|
query = "SELECT * FROM tenant_signups"
|
|
params = []
|
|
|
|
if status:
|
|
query += " WHERE status = $1"
|
|
params.append(status)
|
|
|
|
query += " ORDER BY requested_at DESC"
|
|
|
|
results = await conn.fetch(query, *params)
|
|
return [SignupResponse(**dict(row)) for row in results]
|
|
finally:
|
|
await conn.close()
|
|
|
|
@app.put("/api/v1/signups/{signup_id}/approve")
|
|
async def approve_signup(
|
|
signup_id: int,
|
|
current_user: dict = Depends(get_tenant_admin)
|
|
):
|
|
"""Approve a signup request (tenant admin only)"""
|
|
conn = await asyncpg.connect(DATABASE_URL)
|
|
try:
|
|
# Get signup details to verify tenant access
|
|
signup = await conn.fetchrow(
|
|
"SELECT * FROM tenant_signups WHERE id = $1", signup_id
|
|
)
|
|
if not signup:
|
|
raise HTTPException(status_code=404, detail="Signup not found")
|
|
|
|
# Verify user has access to approve this signup
|
|
if (current_user.get("tenant_id") != signup['tenant_id'] and
|
|
current_user.get("tenant_id") != "admin"):
|
|
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
|
|
|
result = await conn.fetchrow(
|
|
"""
|
|
UPDATE tenant_signups
|
|
SET status = 'approved', approved_by = $2, approved_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1 AND status = 'pending'
|
|
RETURNING *
|
|
""",
|
|
signup_id,
|
|
current_user['sub']
|
|
)
|
|
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Signup not found or already processed")
|
|
|
|
# TODO: Update Auth0 user metadata to set signup_status = 'approved'
|
|
logger.info(f"Approved signup {signup_id} for user {result['user_email']} by {current_user['sub']}")
|
|
|
|
return {"status": "approved", "signup_id": signup_id}
|
|
finally:
|
|
await conn.close()
|
|
|
|
@app.put("/api/v1/signups/{signup_id}/reject")
|
|
async def reject_signup(
|
|
signup_id: int,
|
|
approval_data: SignupApproval,
|
|
current_user: dict = Depends(get_tenant_admin)
|
|
):
|
|
"""Reject a signup request (tenant admin only)"""
|
|
conn = await asyncpg.connect(DATABASE_URL)
|
|
try:
|
|
# Get signup details to verify tenant access
|
|
signup = await conn.fetchrow(
|
|
"SELECT * FROM tenant_signups WHERE id = $1", signup_id
|
|
)
|
|
if not signup:
|
|
raise HTTPException(status_code=404, detail="Signup not found")
|
|
|
|
# Verify user has access to reject this signup
|
|
if (current_user.get("tenant_id") != signup['tenant_id'] and
|
|
current_user.get("tenant_id") != "admin"):
|
|
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
|
|
|
reason = approval_data.reason or "No reason provided"
|
|
|
|
result = await conn.fetchrow(
|
|
"""
|
|
UPDATE tenant_signups
|
|
SET status = 'rejected', approved_by = $2, rejected_at = CURRENT_TIMESTAMP, rejection_reason = $3
|
|
WHERE id = $1 AND status = 'pending'
|
|
RETURNING *
|
|
""",
|
|
signup_id,
|
|
current_user['sub'],
|
|
reason
|
|
)
|
|
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Signup not found or already processed")
|
|
|
|
logger.info(f"Rejected signup {signup_id} for user {result['user_email']}: {reason}")
|
|
|
|
return {"status": "rejected", "signup_id": signup_id, "reason": reason}
|
|
finally:
|
|
await conn.close()
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|