""" 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)