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,333 @@
# Auth0 Multi-Tenant Configuration Guide
This document provides step-by-step instructions for configuring Auth0 for the multi-tenant MotoVaultPro platform.
## Overview
The multi-tenant architecture requires:
- **Landing Page**: `motovaultpro.com` - Entry point with tenant selection
- **Admin Tenant**: `admin.motovaultpro.com` - Admin access to all tenants
- **Regular Tenants**: `{tenant-id}.motovaultpro.com` - Isolated tenant access
- **Signup Workflow**: Tenant-specific signup with admin approval
## Auth0 Application Configuration
### 1. Application Settings
**Application Type**: Single Page Application (SPA)
**Allowed Callback URLs**:
```
# Development URLs
http://localhost:3002/callback
http://admin.motovaultpro.local/callback
http://demo-tenant.motovaultpro.local/callback
# Production URLs
https://motovaultpro.com/callback
https://admin.motovaultpro.com/callback
https://demo-tenant.motovaultpro.com/callback
# Add additional tenant subdomains as needed:
https://{tenant-id}.motovaultpro.com/callback
```
**Allowed Logout URLs**:
```
# Development
http://localhost:3002
http://admin.motovaultpro.local
http://demo-tenant.motovaultpro.local
# Production
https://motovaultpro.com
https://admin.motovaultpro.com
https://demo-tenant.motovaultpro.com
https://{tenant-id}.motovaultpro.com
```
**Allowed Web Origins**:
```
# Development
http://localhost:3002
http://admin.motovaultpro.local:3000
http://demo-tenant.motovaultpro.local:3000
# Production
https://motovaultpro.com
https://admin.motovaultpro.com
https://demo-tenant.motovaultpro.com
https://{tenant-id}.motovaultpro.com
```
### 2. JWT Configuration
**JWT Signature Algorithm**: RS256
**OIDC Conformant**: Enabled
### 3. Advanced Settings
**Grant Types**:
- Authorization Code
- Refresh Token
- Implicit (for development only)
## Auth0 Rules Configuration
### Rule 1: Add Tenant Context to JWT
Create a new Rule in Auth0 Dashboard > Auth Pipeline > Rules:
```javascript
function addTenantContext(user, context, callback) {
const namespace = 'https://motovaultpro.com/';
// Extract tenant_id from user metadata (set during signup)
let tenantId = user.user_metadata && user.user_metadata.tenant_id;
// For existing users without tenant metadata, default to admin
if (!tenantId) {
tenantId = 'admin';
// Optionally update user metadata
user.user_metadata = user.user_metadata || {};
user.user_metadata.tenant_id = tenantId;
}
// Check signup status for non-admin tenants
const signupStatus = user.user_metadata && user.user_metadata.signup_status;
if (tenantId !== 'admin' && signupStatus !== 'approved') {
// Block login for unapproved users
return callback(new UnauthorizedError('Account pending approval'));
}
// Add tenant context to tokens
context.idToken[namespace + 'tenant_id'] = tenantId;
context.accessToken[namespace + 'tenant_id'] = tenantId;
context.idToken[namespace + 'signup_status'] = signupStatus || 'approved';
callback(null, user, context);
}
```
### Rule 2: Tenant-Specific User Metadata
```javascript
function setTenantMetadata(user, context, callback) {
const namespace = 'https://motovaultpro.com/';
// If this is a signup and connection is Username-Password-Authentication
if (context.stats.loginsCount === 1 && context.connection === 'Username-Password-Authentication') {
// Extract tenant from redirect_uri or state parameter
const redirectUri = context.request.query.redirect_uri || '';
const tenantMatch = redirectUri.match(/([a-z0-9-]+)\.motovaultpro\.(com|local)/);
if (tenantMatch) {
const tenantId = tenantMatch[1];
// Set initial user metadata
user.user_metadata = user.user_metadata || {};
user.user_metadata.tenant_id = tenantId;
// Set signup status (pending for regular tenants, approved for admin)
user.user_metadata.signup_status = tenantId === 'admin' ? 'approved' : 'pending';
// Update user metadata in Auth0
auth0.users.updateUserMetadata(user.user_id, user.user_metadata);
}
}
callback(null, user, context);
}
```
## Tenant Signup Flow Configuration
### 1. Signup URLs
**Tenant-Specific Signup**:
```
https://motovaultpro.com/signup/{tenant-id}
```
**Process**:
1. User visits tenant-specific signup URL
2. Landing page validates tenant exists
3. Redirects to Auth0 with tenant context
4. Auth0 Rule sets tenant_id in user metadata
5. User account created with status="pending"
6. Tenant admin receives notification
7. Admin approves/rejects via tenant management API
### 2. Auth0 Hosted Login Customization
Add custom CSS and JavaScript to Auth0 Universal Login to support tenant context:
**Custom CSS** (Dashboard > Universal Login > Advanced Options):
```css
.tenant-signup-info {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
border-left: 4px solid #007bff;
}
```
**Custom JavaScript**:
```javascript
// Extract tenant from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const redirectUri = urlParams.get('redirect_uri') || '';
const tenantMatch = redirectUri.match(/([a-z0-9-]+)\.motovaultpro\.(com|local)/);
if (tenantMatch && tenantMatch[1] !== 'admin') {
const tenantName = tenantMatch[1].replace('-', ' ').toUpperCase();
// Add tenant information to signup form
const container = document.querySelector('.auth0-lock-header');
if (container) {
const info = document.createElement('div');
info.className = 'tenant-signup-info';
info.innerHTML = `
<strong>Signing up for: ${tenantName}</strong><br>
<small>Your account will require admin approval before you can access the system.</small>
`;
container.appendChild(info);
}
}
```
## JWT Token Format
After successful authentication, JWT tokens will include:
**ID Token Claims**:
```json
{
"sub": "auth0|user-123",
"email": "user@example.com",
"https://motovaultpro.com/tenant_id": "demo-tenant",
"https://motovaultpro.com/signup_status": "approved",
"iat": 1699123456,
"exp": 1699127056
}
```
**Access Token Claims**:
```json
{
"sub": "auth0|user-123",
"https://motovaultpro.com/tenant_id": "demo-tenant",
"scope": "openid profile email",
"iat": 1699123456,
"exp": 1699127056
}
```
## Backend JWT Validation
Services should validate JWT tokens and extract tenant context:
```typescript
// Example JWT validation middleware
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: `https://${AUTH0_DOMAIN}/.well-known/jwks.json`
});
function getKey(header: any, callback: any) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
const signingKey = key.getPublicKey();
callback(null, signingKey);
});
}
export const validateJWT = (token: string): Promise<any> => {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
audience: process.env.AUTH0_AUDIENCE,
issuer: `https://${process.env.AUTH0_DOMAIN}/`,
algorithms: ['RS256']
}, (err, decoded) => {
if (err) return reject(err);
resolve(decoded);
});
});
};
// Extract tenant from validated JWT
export const getTenantFromToken = (decodedToken: any): string => {
return decodedToken['https://motovaultpro.com/tenant_id'] || 'admin';
};
```
## Environment Variables
Configure the following environment variables for each service:
**Platform Services**:
```env
AUTH0_DOMAIN=your-domain.auth0.com
AUTH0_AUDIENCE=https://api.motovaultpro.com
```
**Landing Page Service**:
```env
VITE_AUTH0_DOMAIN=your-domain.auth0.com
VITE_AUTH0_CLIENT_ID=your-client-id
VITE_TENANTS_API_URL=http://mvp-platform-tenants:8000
```
**Admin/Tenant Services**:
```env
REACT_APP_AUTH0_DOMAIN=your-domain.auth0.com
REACT_APP_AUTH0_CLIENT_ID=your-client-id
REACT_APP_AUTH0_AUDIENCE=https://api.motovaultpro.com
REACT_APP_TENANT_ID=admin # or specific tenant ID
```
## Testing the Configuration
### 1. Test Admin Login
```bash
# Visit admin tenant
open http://admin.motovaultpro.local
# Should redirect to Auth0, login, then return to admin app
```
### 2. Test Tenant Signup
```bash
# Visit tenant signup
open http://motovaultpro.local/signup/demo-tenant
# Complete signup, verify pending status
curl -H "Authorization: Bearer admin-token" \
http://localhost:8001/api/v1/signups
```
### 3. Test Approval Workflow
```bash
# Approve signup
curl -X PUT -H "Authorization: Bearer admin-token" \
http://localhost:8001/api/v1/signups/1/approve
# User should now be able to login to tenant
open http://demo-tenant.motovaultpro.local
```
## Production Deployment Notes
1. **SSL Certificates**: Ensure wildcard SSL certificate for `*.motovaultpro.com`
2. **DNS Configuration**: Set up wildcard DNS or individual A records per tenant
3. **Auth0 Environment**: Use production Auth0 tenant with proper security settings
4. **Rate Limiting**: Configure Auth0 rate limiting for signup endpoints
5. **Monitoring**: Set up Auth0 logs monitoring for failed login attempts
This configuration provides a secure, scalable multi-tenant authentication system with proper tenant isolation and admin approval workflows.

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)

View File

@@ -0,0 +1,21 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY api/ .
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -0,0 +1,7 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
asyncpg==0.29.0
pydantic==2.5.0
python-jose[cryptography]==3.3.0
python-multipart==0.0.6
httpx==0.25.2

View File

@@ -0,0 +1,41 @@
-- Tenant registry schema for MVP Platform Tenants Service
-- Creates core tenant management tables
-- Tenant registry
CREATE TABLE IF NOT EXISTS tenants (
id VARCHAR(100) PRIMARY KEY, -- 'admin', 'acme-corp', etc.
name VARCHAR(255) NOT NULL, -- Display name
subdomain VARCHAR(100) UNIQUE NOT NULL, -- Same as id for simplicity
status VARCHAR(50) DEFAULT 'active', -- active, pending, suspended
admin_user_id VARCHAR(255), -- Auth0 user ID of tenant admin
settings JSONB DEFAULT '{}', -- Tenant-specific configuration
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status);
CREATE INDEX IF NOT EXISTS idx_tenants_admin_user ON tenants(admin_user_id);
-- Tenant signup approval workflow
CREATE TABLE IF NOT EXISTS tenant_signups (
id SERIAL PRIMARY KEY,
tenant_id VARCHAR(100) REFERENCES tenants(id),
user_email VARCHAR(255) NOT NULL,
user_auth0_id VARCHAR(255), -- Auth0 user ID after signup
status VARCHAR(50) DEFAULT 'pending', -- pending, approved, rejected
requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
approved_by VARCHAR(255), -- Auth0 ID of approving admin
approved_at TIMESTAMP,
rejected_at TIMESTAMP,
rejection_reason TEXT
);
-- Create indexes for signup queries
CREATE INDEX IF NOT EXISTS idx_tenant_signups_tenant_status ON tenant_signups(tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_tenant_signups_user_email ON tenant_signups(user_email);
-- Initial admin tenant data
INSERT INTO tenants (id, name, subdomain, status, admin_user_id)
VALUES ('admin', 'Admin Tenant', 'admin', 'active', NULL)
ON CONFLICT (id) DO NOTHING;