Docs Cleanup

This commit is contained in:
Eric Gullickson
2025-11-02 10:34:43 -06:00
parent a0748ced5b
commit 3693ce5761
60 changed files with 885 additions and 6815 deletions

View File

@@ -1,36 +0,0 @@
FROM node:18-alpine as builder
WORKDIR /app
# Copy package files and install dependencies
COPY package.json ./
RUN npm install
# Copy source code
COPY . .
# Build arguments for environment variables
ARG VITE_AUTH0_DOMAIN
ARG VITE_AUTH0_CLIENT_ID
ARG VITE_TENANTS_API_URL
# Set environment variables for build
ENV VITE_AUTH0_DOMAIN=${VITE_AUTH0_DOMAIN}
ENV VITE_AUTH0_CLIENT_ID=${VITE_AUTH0_CLIENT_ID}
ENV VITE_TENANTS_API_URL=${VITE_TENANTS_API_URL}
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built app to nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MotoVaultPro - Vehicle Management Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,27 +0,0 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Single HTTP server for internal proxying (edge TLS handled by nginx-proxy)
server {
listen 3000;
server_name localhost motovaultpro.com;
root /usr/share/nginx/html;
index index.html;
# Handle React Router (SPA)
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
}
}

View File

@@ -1,26 +0,0 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Handle React Router (SPA)
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
}
}

View File

@@ -1,24 +0,0 @@
{
"name": "mvp-platform-landing",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.8.0",
"@auth0/auth0-react": "^2.2.3",
"axios": "^1.6.2"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.6.3",
"vite": "^5.0.6"
}
}

View File

@@ -1,19 +0,0 @@
import { Routes, Route } from 'react-router-dom'
import HomePage from './components/HomePage'
import TenantSignup from './components/TenantSignup'
import CallbackHandler from './components/CallbackHandler'
function App() {
return (
<div className="App">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/signup/:tenantId" element={<TenantSignup />} />
<Route path="/callback" element={<CallbackHandler />} />
</Routes>
</div>
)
}
export default App

View File

@@ -1,22 +0,0 @@
import React, { useEffect } from 'react'
const CallbackHandler: React.FC = () => {
useEffect(() => {
// This component is no longer needed since we removed Auth0 from landing page
// Redirect to main app
window.location.href = 'https://admin.motovaultpro.com'
}, [])
return (
<div style={{
padding: '2rem',
textAlign: 'center',
fontFamily: 'Arial, sans-serif'
}}>
<h2>Redirecting...</h2>
<p>Please wait while we redirect you to MotoVaultPro.</p>
</div>
)
}
export default CallbackHandler

View File

@@ -1,55 +0,0 @@
import React from 'react'
const HomePage: React.FC = () => {
const handleLogin = () => {
// Redirect directly to admin tenant for login
window.location.href = 'https://admin.motovaultpro.com'
}
return (
<div style={{ padding: '2rem', fontFamily: 'Arial, sans-serif' }}>
<header style={{ textAlign: 'center', marginBottom: '3rem' }}>
<h1>MotoVaultPro</h1>
<p>The complete vehicle management platform for automotive professionals</p>
</header>
<main style={{ maxWidth: '800px', margin: '0 auto' }}>
<section style={{ marginBottom: '3rem' }}>
<h2>Features</h2>
<ul>
<li>Vehicle inventory management</li>
<li>Maintenance tracking and scheduling</li>
<li>Fuel log analytics</li>
<li>Service station locator</li>
<li>Multi-tenant architecture for teams</li>
</ul>
</section>
<section style={{ textAlign: 'center' }}>
<h2>Get Started</h2>
<p>Already have an account?</p>
<button
onClick={handleLogin}
style={{
padding: '1rem 2rem',
fontSize: '1.1rem',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Access Your Dashboard
</button>
<p style={{ marginTop: '2rem' }}>
Need to join a team? Contact your tenant administrator for an invitation.
</p>
</section>
</main>
</div>
)
}
export default HomePage

View File

@@ -1,109 +0,0 @@
import React, { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useAuth0 } from '@auth0/auth0-react'
import axios from 'axios'
interface TenantInfo {
id: string
name: string
status: string
}
const TenantSignup: React.FC = () => {
const { tenantId } = useParams<{ tenantId: string }>()
const { loginWithRedirect } = useAuth0()
const [tenant, setTenant] = useState<TenantInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchTenant = async () => {
try {
const response = await axios.get(
`${import.meta.env.VITE_TENANTS_API_URL}/api/v1/tenants/${tenantId}`
)
setTenant(response.data)
} catch (err) {
setError('Tenant not found or not accepting signups')
} finally {
setLoading(false)
}
}
if (tenantId) {
fetchTenant()
}
}, [tenantId])
const handleSignup = async () => {
await loginWithRedirect({
authorizationParams: {
screen_hint: 'signup',
redirect_uri: `${window.location.origin}/callback`
}
})
}
if (loading) {
return <div style={{ padding: '2rem' }}>Loading...</div>
}
if (error || !tenant) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>Tenant Not Found</h2>
<p>{error}</p>
<a href="/">Return to Homepage</a>
</div>
)
}
return (
<div style={{ padding: '2rem', maxWidth: '600px', margin: '0 auto', fontFamily: 'Arial, sans-serif' }}>
<header style={{ textAlign: 'center', marginBottom: '2rem' }}>
<h1>Join {tenant.name}</h1>
<p>Create your account to get started</p>
</header>
<main>
<div style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '2rem',
backgroundColor: '#f9f9f9'
}}>
<h3>What happens next?</h3>
<ol>
<li>Create your account with Auth0</li>
<li>Your signup request will be sent to the tenant administrator</li>
<li>Once approved, you'll receive access to {tenant.name}</li>
<li>Login at <code>{tenant.id}.motovaultpro.com</code></li>
</ol>
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
<button
onClick={handleSignup}
style={{
padding: '1rem 2rem',
fontSize: '1.1rem',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Create Account for {tenant.name}
</button>
</div>
</div>
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
<a href="/"> Back to Homepage</a>
</div>
</main>
</div>
)
}
export default TenantSignup

View File

@@ -1,12 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
)

View File

@@ -1,11 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_AUTH0_DOMAIN: string
readonly VITE_AUTH0_CLIENT_ID: string
readonly VITE_TENANTS_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,9 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}

View File

@@ -1,14 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 3000
},
build: {
outDir: 'dist',
sourcemap: true
}
})

View File

@@ -1,333 +0,0 @@
# 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

@@ -1,525 +0,0 @@
"""
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

@@ -1,21 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,41 +0,0 @@
-- 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;