Initial Commit
This commit is contained in:
525
mvp-platform-services/tenants/api/main.py
Normal file
525
mvp-platform-services/tenants/api/main.py
Normal file
@@ -0,0 +1,525 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user