Initial Commit
This commit is contained in:
333
mvp-platform-services/tenants/AUTH0-CONFIG.md
Normal file
333
mvp-platform-services/tenants/AUTH0-CONFIG.md
Normal 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.
|
||||
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)
|
||||
21
mvp-platform-services/tenants/docker/Dockerfile.api
Normal file
21
mvp-platform-services/tenants/docker/Dockerfile.api
Normal 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"]
|
||||
7
mvp-platform-services/tenants/requirements.txt
Normal file
7
mvp-platform-services/tenants/requirements.txt
Normal 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
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user