Initial Commit

This commit is contained in:
Eric Gullickson
2025-09-17 16:09:15 -05:00
parent 0cdb9803de
commit a052040e3a
373 changed files with 437090 additions and 6773 deletions

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