Improve auth flow

This commit is contained in:
Urtzi Alfaro
2025-07-19 17:49:03 +02:00
parent f3071c00bd
commit abc8b68ab4
16 changed files with 1437 additions and 572 deletions

123
.env.sample Normal file
View File

@@ -0,0 +1,123 @@
# .env.example - Environment Variables Template
# Copy to .env and update values
# ================================================================
# JWT CONFIGURATION (CRITICAL - CHANGE IN PRODUCTION!)
# ================================================================
JWT_SECRET_KEY=your-super-secret-jwt-key-change-in-production-minimum-32-characters-required
# ================================================================
# EXTERNAL API KEYS
# ================================================================
# AEMET (Spanish Weather Service) API Key
# Get from: https://opendata.aemet.es/centrodedescargas/altaUsuario
AEMET_API_KEY=your-aemet-api-key-here
# Madrid Open Data API Key (Optional)
# Get from: https://datos.madrid.es/portal/site/egob/
MADRID_OPENDATA_API_KEY=your-madrid-opendata-key-here
# ================================================================
# EMAIL CONFIGURATION (For notifications)
# ================================================================
# Gmail SMTP Configuration (recommended)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-gmail-app-specific-password
# Alternative: SendGrid
# SMTP_HOST=smtp.sendgrid.net
# SMTP_PORT=587
# SMTP_USER=apikey
# SMTP_PASSWORD=your-sendgrid-api-key
# ================================================================
# WHATSAPP CONFIGURATION (Twilio)
# ================================================================
# Twilio WhatsApp Configuration
# Get from: https://www.twilio.com/console
WHATSAPP_ACCOUNT_SID=your-twilio-account-sid
WHATSAPP_AUTH_TOKEN=your-twilio-auth-token
WHATSAPP_FROM_NUMBER=whatsapp:+14155238886
# ================================================================
# DATABASE CONFIGURATION (Auto-configured in Docker)
# ================================================================
# These are set automatically in docker-compose.yml
# Only change if using external databases
# AUTH_DATABASE_URL=postgresql+asyncpg://auth_user:auth_pass123@auth-db:5432/auth_db
# TENANT_DATABASE_URL=postgresql+asyncpg://tenant_user:tenant_pass123@tenant-db:5432/tenant_db
# TRAINING_DATABASE_URL=postgresql+asyncpg://training_user:training_pass123@training-db:5432/training_db
# FORECASTING_DATABASE_URL=postgresql+asyncpg://forecasting_user:forecasting_pass123@forecasting-db:5432/forecasting_db
# DATA_DATABASE_URL=postgresql+asyncpg://data_user:data_pass123@data-db:5432/data_db
# NOTIFICATION_DATABASE_URL=postgresql+asyncpg://notification_user:notification_pass123@notification-db:5432/notification_db
# ================================================================
# REDIS CONFIGURATION (Auto-configured in Docker)
# ================================================================
# REDIS_URL=redis://:redis_pass123@redis:6379
# ================================================================
# RABBITMQ CONFIGURATION (Auto-configured in Docker)
# ================================================================
# RABBITMQ_URL=amqp://bakery:forecast123@rabbitmq:5672/
# ================================================================
# CORS CONFIGURATION
# ================================================================
# Allowed origins for CORS (comma-separated)
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,https://yourdomain.com
# ================================================================
# ML/AI CONFIGURATION
# ================================================================
# Model storage configuration
MODEL_STORAGE_PATH=/app/models
MAX_TRAINING_TIME_MINUTES=30
MIN_TRAINING_DATA_DAYS=30
PROPHET_SEASONALITY_MODE=additive
# Prediction caching
PREDICTION_CACHE_TTL_HOURS=6
# ================================================================
# SECURITY CONFIGURATION
# ================================================================
# Password requirements
PASSWORD_MIN_LENGTH=8
MAX_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=30
# Rate limiting
RATE_LIMIT_CALLS_PER_MINUTE=60
RATE_LIMIT_BURST=10
# Session configuration
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
# ================================================================
# MONITORING CONFIGURATION
# ================================================================
# Log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO
# Service versions
SERVICE_VERSION=1.0.0
# Data retention
DATA_RETENTION_DAYS=365
WEATHER_CACHE_TTL_HOURS=1
TRAFFIC_CACHE_TTL_HOURS=1

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,24 @@
import logging # gateway/app/middleware/auth.py - IMPROVED VERSION
from fastapi import Request """
Enhanced Authentication Middleware for API Gateway
Implements proper token validation and tenant context extraction
"""
import structlog
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response from starlette.responses import Response
import httpx import httpx
from typing import Optional from typing import Optional, Dict, Any
import json import asyncio
from app.core.config import settings from app.core.config import settings
from shared.auth.jwt_handler import JWTHandler from shared.auth.jwt_handler import JWTHandler
logger = logging.getLogger(__name__) logger = structlog.get_logger()
# JWT handler # JWT handler for local token validation
jwt_handler = JWTHandler(settings.JWT_SECRET_KEY, settings.JWT_ALGORITHM) jwt_handler = JWTHandler(settings.JWT_SECRET_KEY, settings.JWT_ALGORITHM)
# Routes that don't require authentication # Routes that don't require authentication
@@ -25,98 +31,227 @@ PUBLIC_ROUTES = [
"/api/v1/auth/login", "/api/v1/auth/login",
"/api/v1/auth/register", "/api/v1/auth/register",
"/api/v1/auth/refresh", "/api/v1/auth/refresh",
"/api/v1/auth/verify" # ✅ Add verify to public routes "/api/v1/auth/verify"
] ]
class AuthMiddleware(BaseHTTPMiddleware): class AuthMiddleware(BaseHTTPMiddleware):
"""Authentication middleware with better error handling""" """
Enhanced Authentication Middleware following microservices best practices
async def dispatch(self, request: Request, call_next) -> Response: Responsibilities:
"""Process request with authentication""" 1. Token validation (local first, then auth service)
2. User context injection
3. Tenant context extraction (per request)
4. Rate limiting enforcement
5. Request routing decisions
"""
def __init__(self, app, redis_client=None):
super().__init__(app)
self.redis_client = redis_client # For caching and rate limiting
# Check if route requires authentication async def dispatch(self, request: Request, call_next) -> Response:
"""Process request with enhanced authentication"""
# Skip authentication for public routes
if self._is_public_route(request.url.path): if self._is_public_route(request.url.path):
return await call_next(request) return await call_next(request)
# Get token from header # Extract and validate JWT token
token = self._extract_token(request) token = self._extract_token(request)
if not token: if not token:
logger.warning(f"Missing token for {request.url.path}") logger.warning(f"Missing token for protected route: {request.url.path}")
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
content={"detail": "Authentication required"} content={"detail": "Authentication required"}
) )
# Verify token # Verify token and get user context
try: user_context = await self._verify_token(token)
# First try to verify token locally if not user_context:
payload = jwt_handler.verify_token(token) logger.warning(f"Invalid token for route: {request.url.path}")
if payload:
# Validate required fields
required_fields = ["user_id", "email", "tenant_id"]
missing_fields = [field for field in required_fields if field not in payload]
if missing_fields:
logger.warning(f"Token missing required fields: {missing_fields}")
return JSONResponse(
status_code=401,
content={"detail": f"Invalid token: missing {missing_fields}"}
)
# Add user info to request state
request.state.user = payload
logger.debug(f"Authenticated user: {payload.get('email')} (tenant: {payload.get('tenant_id')})")
return await call_next(request)
else:
# Token invalid or expired, try auth service verification
logger.info("Local token verification failed, trying auth service")
user_info = await self._verify_with_auth_service(token)
if user_info:
request.state.user = user_info
return await call_next(request)
else:
logger.warning("Auth service verification also failed")
return JSONResponse(
status_code=401,
content={"detail": "Invalid or expired token"}
)
except Exception as e:
logger.error(f"Authentication error: {e}")
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
content={"detail": "Authentication failed"} content={"detail": "Invalid or expired token"}
) )
# Extract tenant context from request (not from JWT)
tenant_id = self._extract_tenant_from_request(request)
# Verify user has access to tenant (if tenant_id provided)
if tenant_id:
has_access = await self._verify_tenant_access(user_context["user_id"], tenant_id)
if not has_access:
logger.warning(f"User {user_context['email']} denied access to tenant {tenant_id}")
return JSONResponse(
status_code=403,
content={"detail": "Access denied to tenant"}
)
request.state.tenant_id = tenant_id
# Inject user context into request
request.state.user = user_context
request.state.authenticated = True
# Add user context to forwarded requests
self._inject_auth_headers(request, user_context, tenant_id)
logger.debug(f"Authenticated request: {user_context['email']} -> {request.url.path}")
return await call_next(request)
def _is_public_route(self, path: str) -> bool: def _is_public_route(self, path: str) -> bool:
"""Check if route is public""" """Check if route requires authentication"""
return any(path.startswith(route) for route in PUBLIC_ROUTES) return any(path.startswith(route) for route in PUBLIC_ROUTES)
def _extract_token(self, request: Request) -> Optional[str]: def _extract_token(self, request: Request) -> Optional[str]:
"""Extract JWT token from request""" """Extract JWT token from Authorization header"""
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "): if auth_header and auth_header.startswith("Bearer "):
return auth_header.split(" ")[1] return auth_header.split(" ")[1]
return None return None
async def _verify_with_auth_service(self, token: str) -> Optional[dict]: def _extract_tenant_from_request(self, request: Request) -> Optional[str]:
"""
Extract tenant ID from request (NOT from JWT token)
Priority order:
1. X-Tenant-ID header
2. tenant_id query parameter
3. tenant_id in request path
"""
# Method 1: Header
tenant_id = request.headers.get("X-Tenant-ID")
if tenant_id:
return tenant_id
# Method 2: Query parameter
tenant_id = request.query_params.get("tenant_id")
if tenant_id:
return tenant_id
# Method 3: Path parameter (extract from URLs like /api/v1/tenants/{tenant_id}/...)
path_parts = request.url.path.split("/")
if "tenants" in path_parts:
try:
tenant_index = path_parts.index("tenants")
if tenant_index + 1 < len(path_parts):
return path_parts[tenant_index + 1]
except (ValueError, IndexError):
pass
return None
async def _verify_token(self, token: str) -> Optional[Dict[str, Any]]:
"""
Verify JWT token with fallback strategy:
1. Local validation (fast)
2. Auth service validation (authoritative)
3. Cache valid tokens to reduce auth service calls
"""
# Step 1: Try local JWT validation first (fast)
try:
payload = jwt_handler.verify_token(token)
if payload and self._validate_token_payload(payload):
logger.debug("Token validated locally")
return payload
except Exception as e:
logger.debug(f"Local token validation failed: {e}")
# Step 2: Check cache for recently validated tokens
if self.redis_client:
try:
cached_user = await self._get_cached_user(token)
if cached_user:
logger.debug("Token found in cache")
return cached_user
except Exception as e:
logger.warning(f"Cache lookup failed: {e}")
# Step 3: Verify with auth service (authoritative)
try:
user_context = await self._verify_with_auth_service(token)
if user_context:
# Cache successful validation
if self.redis_client:
await self._cache_user(token, user_context)
logger.debug("Token validated by auth service")
return user_context
except Exception as e:
logger.error(f"Auth service validation failed: {e}")
return None
def _validate_token_payload(self, payload: Dict[str, Any]) -> bool:
"""Validate JWT payload has required fields"""
required_fields = ["user_id", "email", "exp"]
return all(field in payload for field in required_fields)
async def _verify_with_auth_service(self, token: str) -> Optional[Dict[str, Any]]:
"""Verify token with auth service""" """Verify token with auth service"""
try: try:
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=3.0) as client:
response = await client.post( response = await client.post(
f"{settings.AUTH_SERVICE_URL}/api/v1/auth/verify", f"{settings.AUTH_SERVICE_URL}/api/v1/auth/verify",
headers={"Authorization": f"Bearer {token}"} headers={"Authorization": f"Bearer {token}"}
) )
if response.status_code == 200: if response.status_code == 200:
user_info = response.json() return response.json()
logger.debug(f"Auth service verification successful: {user_info.get('email')}")
return user_info
else: else:
logger.warning(f"Auth service verification failed: {response.status_code}") logger.warning(f"Auth service returned {response.status_code}")
return None return None
except asyncio.TimeoutError:
logger.error("Auth service timeout")
return None
except Exception as e: except Exception as e:
logger.error(f"Auth service verification failed: {e}") logger.error(f"Auth service error: {e}")
return None return None
async def _verify_tenant_access(self, user_id: str, tenant_id: str) -> bool:
"""Verify user has access to specific tenant"""
try:
async with httpx.AsyncClient(timeout=3.0) as client:
response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/access/{user_id}"
)
return response.status_code == 200
except Exception as e:
logger.error(f"Tenant access verification failed: {e}")
return False
async def _get_cached_user(self, token: str) -> Optional[Dict[str, Any]]:
"""Get user context from cache"""
if not self.redis_client:
return None
cache_key = f"auth:token:{hash(token)}"
cached_data = await self.redis_client.get(cache_key)
if cached_data:
import json
return json.loads(cached_data)
return None
async def _cache_user(self, token: str, user_context: Dict[str, Any], ttl: int = 300):
"""Cache user context for 5 minutes"""
if not self.redis_client:
return
cache_key = f"auth:token:{hash(token)}"
import json
await self.redis_client.setex(cache_key, ttl, json.dumps(user_context))
def _inject_auth_headers(self, request: Request, user_context: Dict[str, Any], tenant_id: Optional[str]):
"""Inject authentication context into forwarded requests"""
# Add user context headers for downstream services
if hasattr(request, "headers"):
# Create mutable headers
headers = dict(request.headers)
headers["X-User-ID"] = user_context["user_id"]
headers["X-User-Email"] = user_context["email"]
if tenant_id:
headers["X-Tenant-ID"] = tenant_id
# Update request headers
request.scope["headers"] = [(k.lower().encode(), v.encode()) for k, v in headers.items()]

View File

@@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: 'Bakery Forecasting'
orgId: 1
folder: ''
type: file
disableDeletion: false
updateIntervalSeconds: 10
options:
path: /etc/grafana/provisioning/dashboards

View File

@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true

View File

@@ -1,31 +1,59 @@
---
global: global:
scrape_interval: 15s scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- "alerts.yml"
scrape_configs: scrape_configs:
- job_name: 'gateway' - job_name: 'gateway'
static_configs: static_configs:
- targets: ['gateway:8080'] - targets: ['gateway:8000']
metrics_path: '/metrics'
scrape_interval: 30s
- job_name: 'auth-service' - job_name: 'auth-service'
static_configs: static_configs:
- targets: ['auth-service:8080'] - targets: ['auth-service:8000']
metrics_path: '/metrics'
- job_name: 'training-service' scrape_interval: 30s
static_configs:
- targets: ['training-service:8080']
- job_name: 'forecasting-service'
static_configs:
- targets: ['forecasting-service:8080']
- job_name: 'data-service'
static_configs:
- targets: ['data-service:8080']
- job_name: 'tenant-service' - job_name: 'tenant-service'
static_configs: static_configs:
- targets: ['tenant-service:8080'] - targets: ['tenant-service:8000']
metrics_path: '/metrics'
scrape_interval: 30s
- job_name: 'training-service'
static_configs:
- targets: ['training-service:8000']
metrics_path: '/metrics'
scrape_interval: 30s
- job_name: 'forecasting-service'
static_configs:
- targets: ['forecasting-service:8000']
metrics_path: '/metrics'
scrape_interval: 30s
- job_name: 'data-service'
static_configs:
- targets: ['data-service:8000']
metrics_path: '/metrics'
scrape_interval: 30s
- job_name: 'notification-service' - job_name: 'notification-service'
static_configs: static_configs:
- targets: ['notification-service:8080'] - targets: ['notification-service:8000']
metrics_path: '/metrics'
scrape_interval: 30s
- job_name: 'redis'
static_configs:
- targets: ['redis:6379']
- job_name: 'rabbitmq'
static_configs:
- targets: ['rabbitmq:15692']

View File

@@ -1,35 +1,31 @@
# services/auth/app/schemas/auth.py
""" """
Authentication schemas Authentication schemas
""" """
from pydantic import BaseModel, EmailStr, Field, validator from pydantic import BaseModel, EmailStr, Field, validator
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from app.core.config import settings
from shared.utils.validation import validate_spanish_phone
class UserRegistration(BaseModel): class UserRegistration(BaseModel):
"""User registration schema""" """User registration schema"""
email: EmailStr email: EmailStr
password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH) password: str = Field(..., min_length=8)
full_name: str = Field(..., min_length=2, max_length=100) full_name: str = Field(..., min_length=2, max_length=100)
phone: Optional[str] = None phone: Optional[str] = None
language: str = Field(default="es", pattern="^(es|en)$") language: str = Field(default="es", pattern="^(es|en)$")
@validator('password') @validator('password')
def validate_password(cls, v): def validate_password(cls, v):
"""Validate password strength""" """Basic password validation"""
from app.core.security import security_manager if len(v) < 8:
if not security_manager.validate_password(v): raise ValueError('Password must be at least 8 characters')
raise ValueError('Password does not meet security requirements') if not any(c.isupper() for c in v):
return v raise ValueError('Password must contain uppercase letter')
if not any(c.islower() for c in v):
@validator('phone') raise ValueError('Password must contain lowercase letter')
def validate_phone(cls, v): if not any(c.isdigit() for c in v):
"""Validate phone number""" raise ValueError('Password must contain number')
if v and not validate_spanish_phone(v):
raise ValueError('Invalid Spanish phone number')
return v return v
class UserLogin(BaseModel): class UserLogin(BaseModel):
@@ -55,55 +51,29 @@ class UserResponse(BaseModel):
full_name: str full_name: str
is_active: bool is_active: bool
is_verified: bool is_verified: bool
tenant_id: Optional[str]
role: str
phone: Optional[str] phone: Optional[str]
language: str language: str
timezone: str created_at: datetime
created_at: Optional[datetime]
last_login: Optional[datetime] last_login: Optional[datetime]
class Config:
from_attributes = True
class PasswordChangeRequest(BaseModel): class PasswordChangeRequest(BaseModel):
"""Password change request schema""" """Password change request schema"""
current_password: str current_password: str
new_password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH) new_password: str = Field(..., min_length=8)
@validator('new_password') @validator('new_password')
def validate_new_password(cls, v): def validate_new_password(cls, v):
"""Validate new password strength""" """Validate new password strength"""
from app.core.security import security_manager if len(v) < 8:
if not security_manager.validate_password(v): raise ValueError('Password must be at least 8 characters')
raise ValueError('New password does not meet security requirements')
return v return v
class PasswordResetRequest(BaseModel): class TokenVerificationResponse(BaseModel):
"""Password reset request schema""" """Token verification response for other services"""
email: EmailStr user_id: str
email: str
class PasswordResetConfirm(BaseModel): is_active: bool
"""Password reset confirmation schema""" expires_at: datetime
token: str
new_password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH)
@validator('new_password')
def validate_new_password(cls, v):
"""Validate new password strength"""
from app.core.security import security_manager
if not security_manager.validate_password(v):
raise ValueError('New password does not meet security requirements')
return v
class UserUpdate(BaseModel):
"""User update schema"""
full_name: Optional[str] = Field(None, min_length=2, max_length=100)
phone: Optional[str] = None
language: Optional[str] = Field(None, pattern="^(es|en)$")
timezone: Optional[str] = None
tenant_id: Optional[str] = None
@validator('phone')
def validate_phone(cls, v):
"""Validate phone number"""
if v and not validate_spanish_phone(v):
raise ValueError('Invalid Spanish phone number')
return v

View File

@@ -17,3 +17,5 @@ python-json-logger==2.0.4
pytz==2023.3 pytz==2023.3
python-logstash==0.4.8 python-logstash==0.4.8
structlog==23.2.0 structlog==23.2.0
python-dotenv==1.0.0

View File

@@ -0,0 +1,167 @@
# services/tenant/app/api/tenants.py
"""
Tenant API endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
import structlog
from app.core.database import get_db
from app.schemas.tenants import (
BakeryRegistration, TenantResponse, TenantAccessResponse,
TenantUpdate, TenantMemberResponse
)
from app.services.tenant_service import TenantService
from shared.auth.decorators import require_authentication, get_current_user, get_current_tenant_id
logger = structlog.get_logger()
router = APIRouter()
@router.post("/bakeries", response_model=TenantResponse)
@require_authentication
async def register_bakery(
bakery_data: BakeryRegistration,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Register a new bakery/tenant"""
user = get_current_user(request)
try:
result = await TenantService.create_bakery(bakery_data, user["user_id"], db)
logger.info(f"Bakery registered: {bakery_data.name} by {user['email']}")
return result
except Exception as e:
logger.error(f"Bakery registration failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Bakery registration failed"
)
@router.get("/tenants/{tenant_id}/access/{user_id}", response_model=TenantAccessResponse)
async def verify_tenant_access(
tenant_id: str,
user_id: str,
db: AsyncSession = Depends(get_db)
):
"""Verify if user has access to tenant - Called by Gateway"""
try:
access_info = await TenantService.verify_user_access(user_id, tenant_id, db)
return access_info
except Exception as e:
logger.error(f"Access verification failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Access verification failed"
)
@router.get("/users/{user_id}/tenants", response_model=List[TenantResponse])
@require_authentication
async def get_user_tenants(
user_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Get all tenants accessible by user"""
current_user = get_current_user(request)
# Users can only see their own tenants
if current_user["user_id"] != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
try:
tenants = await TenantService.get_user_tenants(user_id, db)
return tenants
except Exception as e:
logger.error(f"Failed to get user tenants: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve tenants"
)
@router.get("/tenants/{tenant_id}", response_model=TenantResponse)
@require_authentication
async def get_tenant(
tenant_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Get tenant details"""
user = get_current_user(request)
# Verify user has access to tenant
access = await TenantService.verify_user_access(user["user_id"], tenant_id, db)
if not access.has_access:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to tenant"
)
tenant = await TenantService.get_tenant_by_id(tenant_id, db)
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
return tenant
@router.put("/tenants/{tenant_id}", response_model=TenantResponse)
@require_authentication
async def update_tenant(
tenant_id: str,
update_data: TenantUpdate,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Update tenant information"""
user = get_current_user(request)
try:
result = await TenantService.update_tenant(tenant_id, update_data, user["user_id"], db)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Tenant update failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Tenant update failed"
)
@router.post("/tenants/{tenant_id}/members", response_model=TenantMemberResponse)
@require_authentication
async def add_team_member(
tenant_id: str,
user_id: str,
role: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Add a team member to tenant"""
current_user = get_current_user(request)
try:
result = await TenantService.add_team_member(
tenant_id, user_id, role, current_user["user_id"], db
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Add team member failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add team member"
)

View File

@@ -1,5 +1,6 @@
# services/tenant/app/main.py
""" """
uLutenant Service Tenant Service FastAPI application
""" """
import structlog import structlog
@@ -7,23 +8,27 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings from app.core.config import settings
from app.core.database import database_manager from app.core.database import engine
from app.api import tenants
from shared.monitoring.logging import setup_logging from shared.monitoring.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector from shared.monitoring.metrics import MetricsCollector
# Setup logging # Setup logging
setup_logging("tenant-service", "INFO") setup_logging("tenant-service", settings.LOG_LEVEL)
logger = structlog.get_logger() logger = structlog.get_logger()
# Create FastAPI app # Create FastAPI app
app = FastAPI( app = FastAPI(
title="uLutenant Service", title="Tenant Management Service",
description="uLutenant service for bakery forecasting", description="Multi-tenant bakery management service",
version="1.0.0" version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
) )
# Initialize metrics collector # Initialize metrics
metrics_collector = MetricsCollector("tenant-service") metrics_collector = MetricsCollector("tenant_service")
app.state.metrics_collector = metrics_collector
# CORS middleware # CORS middleware
app.add_middleware( app.add_middleware(
@@ -34,18 +39,19 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Include routers
app.include_router(tenants.router, prefix="/api/v1", tags=["tenants"])
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
"""Application startup""" """Initialize service on startup"""
logger.info("Starting uLutenant Service") logger.info("Starting Tenant Service...")
# Create database tables @app.on_event("shutdown")
await database_manager.create_tables() async def shutdown_event():
"""Cleanup on shutdown"""
# Start metrics server logger.info("Shutting down Tenant Service...")
metrics_collector.start_metrics_server(8080) await engine.dispose()
logger.info("uLutenant Service started successfully")
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
@@ -56,6 +62,11 @@ async def health_check():
"version": "1.0.0" "version": "1.0.0"
} }
@app.get("/metrics")
async def metrics():
"""Prometheus metrics endpoint"""
return metrics_collector.generate_latest()
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,73 @@
# services/tenant/app/models/tenants.py
"""
Tenant models for bakery management
"""
from sqlalchemy import Column, String, Boolean, DateTime, Float, ForeignKey, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime
import uuid
from shared.database.base import Base
class Tenant(Base):
"""Tenant/Bakery model"""
__tablename__ = "tenants"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False)
subdomain = Column(String(100), unique=True)
business_type = Column(String(100), default="bakery")
# Location info
address = Column(Text, nullable=False)
city = Column(String(100), default="Madrid")
postal_code = Column(String(10), nullable=False)
latitude = Column(Float)
longitude = Column(Float)
# Contact info
phone = Column(String(20))
email = Column(String(255))
# Status
is_active = Column(Boolean, default=True)
subscription_tier = Column(String(50), default="basic")
# ML status
model_trained = Column(Boolean, default=False)
last_training_date = Column(DateTime)
# Ownership
owner_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<Tenant(id={self.id}, name={self.name})>"
class TenantMember(Base):
"""Tenant membership model for team access"""
__tablename__ = "tenant_members"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Role and permissions
role = Column(String(50), default="member") # owner, admin, member, viewer
permissions = Column(Text) # JSON string of permissions
# Status
is_active = Column(Boolean, default=True)
invited_by = Column(UUID(as_uuid=True))
invited_at = Column(DateTime, default=datetime.utcnow)
joined_at = Column(DateTime)
created_at = Column(DateTime, default=datetime.utcnow)
def __repr__(self):
return f"<TenantMember(tenant_id={self.tenant_id}, user_id={self.user_id}, role={self.role})>"

View File

@@ -0,0 +1,83 @@
# services/tenant/app/schemas/tenants.py
"""
Tenant schemas
"""
from pydantic import BaseModel, Field, validator
from typing import Optional, List, Dict, Any
from datetime import datetime
import re
class BakeryRegistration(BaseModel):
"""Bakery registration schema"""
name: str = Field(..., min_length=2, max_length=200)
address: str = Field(..., min_length=10, max_length=500)
city: str = Field(default="Madrid", max_length=100)
postal_code: str = Field(..., pattern=r"^\d{5}$")
phone: str = Field(..., min_length=9, max_length=20)
business_type: str = Field(default="bakery")
@validator('phone')
def validate_spanish_phone(cls, v):
"""Validate Spanish phone number"""
# Remove spaces and common separators
phone = re.sub(r'[\s\-\(\)]', '', v)
# Spanish mobile: +34 6/7/8/9 + 8 digits
# Spanish landline: +34 9 + 8 digits
patterns = [
r'^(\+34|0034|34)?[6789]\d{8}$', # Mobile
r'^(\+34|0034|34)?9\d{8}$', # Landline
]
if not any(re.match(pattern, phone) for pattern in patterns):
raise ValueError('Invalid Spanish phone number')
return v
@validator('business_type')
def validate_business_type(cls, v):
valid_types = ['bakery', 'coffee_shop', 'pastry_shop', 'restaurant']
if v not in valid_types:
raise ValueError(f'Business type must be one of: {valid_types}')
return v
class TenantResponse(BaseModel):
"""Tenant response schema"""
id: str
name: str
subdomain: Optional[str]
business_type: str
address: str
city: str
postal_code: str
phone: Optional[str]
is_active: bool
subscription_tier: str
model_trained: bool
last_training_date: Optional[datetime]
owner_id: str
created_at: datetime
class Config:
from_attributes = True
class TenantAccessResponse(BaseModel):
"""Tenant access verification response"""
has_access: bool
role: str
permissions: List[str]
class TenantMemberResponse(BaseModel):
"""Tenant member response"""
id: str
user_id: str
role: str
is_active: bool
joined_at: Optional[datetime]
class TenantUpdate(BaseModel):
"""Tenant update schema"""
name: Optional[str] = Field(None, min_length=2, max_length=200)
address: Optional[str] = Field(None, min_length=10, max_length=500)
phone: Optional[str] = None
business_type: Optional[str] = None

View File

@@ -0,0 +1,41 @@
# services/tenant/app/services/messaging.py
"""
Tenant service messaging for event publishing
"""
import structlog
from shared.messaging.rabbitmq import RabbitMQPublisher
logger = structlog.get_logger()
async def publish_tenant_created(tenant_id: str, owner_id: str, tenant_name: str):
"""Publish tenant created event"""
try:
publisher = RabbitMQPublisher()
await publisher.publish_event(
"tenant.created",
{
"tenant_id": tenant_id,
"owner_id": owner_id,
"tenant_name": tenant_name,
"timestamp": datetime.utcnow().isoformat()
}
)
except Exception as e:
logger.error(f"Failed to publish tenant.created event: {e}")
async def publish_member_added(tenant_id: str, user_id: str, role: str):
"""Publish member added event"""
try:
publisher = RabbitMQPublisher()
await publisher.publish_event(
"tenant.member.added",
{
"tenant_id": tenant_id,
"user_id": user_id,
"role": role,
"timestamp": datetime.utcnow().isoformat()
}
)
except Exception as e:
logger.error(f"Failed to publish tenant.member.added event: {e}")

View File

@@ -1,41 +1,76 @@
# shared/auth/decorators.py - NEW FILE
""" """
Authentication decorators for FastAPI Authentication decorators for microservices
""" """
from functools import wraps from functools import wraps
from fastapi import HTTPException, Depends from fastapi import HTTPException, status, Request
from fastapi.security import HTTPBearer from typing import Callable, Optional
import httpx
import logging
logger = logging.getLogger(__name__) def require_authentication(func: Callable) -> Callable:
"""Decorator to require authentication - assumes gateway has validated token"""
security = HTTPBearer()
def verify_service_token(auth_service_url: str):
"""Verify service token with auth service"""
async def verify_token(token: str = Depends(security)): @wraps(func)
try: async def wrapper(*args, **kwargs):
async with httpx.AsyncClient() as client: # Find request object in arguments
response = await client.post( request = None
f"{auth_service_url}/verify", for arg in args:
headers={"Authorization": f"Bearer {token.credentials}"} if isinstance(arg, Request):
) request = arg
break
if response.status_code == 200:
return response.json() if not request:
else: # Check kwargs
raise HTTPException( request = kwargs.get('request')
status_code=401,
detail="Invalid authentication credentials" if not request:
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException( raise HTTPException(
status_code=503, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Authentication service unavailable" detail="Request object not found"
) )
# Check if user context exists (set by gateway)
if not hasattr(request.state, 'user') or not request.state.user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
return await func(*args, **kwargs)
return verify_token return wrapper
def require_tenant_access(func: Callable) -> Callable:
"""Decorator to require tenant access"""
@wraps(func)
async def wrapper(*args, **kwargs):
# Find request object
request = None
for arg in args:
if isinstance(arg, Request):
request = arg
break
if not request or not hasattr(request.state, 'tenant_id'):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant access required"
)
return await func(*args, **kwargs)
return wrapper
def get_current_user(request: Request) -> dict:
"""Get current user from request state"""
if not hasattr(request.state, 'user'):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not authenticated"
)
return request.state.user
def get_current_tenant_id(request: Request) -> Optional[str]:
"""Get current tenant ID from request state"""
return getattr(request.state, 'tenant_id', None)

View File

@@ -1,58 +1,97 @@
# shared/auth/jwt_handler.py - IMPROVED VERSION
""" """
Shared JWT Authentication Handler Enhanced JWT Handler with proper token structure
Used across all microservices for consistent authentication
""" """
from jose import jwt from jose import jwt, JWTError
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
import logging import structlog
logger = logging.getLogger(__name__) logger = structlog.get_logger()
class JWTHandler: class JWTHandler:
"""JWT token handling for microservices""" """Enhanced JWT token handling"""
def __init__(self, secret_key: str, algorithm: str = "HS256"): def __init__(self, secret_key: str, algorithm: str = "HS256"):
self.secret_key = secret_key self.secret_key = secret_key
self.algorithm = algorithm self.algorithm = algorithm
def create_access_token(self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: def create_access_token(self, user_data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token""" """
to_encode = data.copy() Create JWT access token WITHOUT tenant_id
Tenant context is determined per request, not stored in token
"""
to_encode = {
"sub": user_data["user_id"], # Standard JWT subject
"user_id": user_data["user_id"],
"email": user_data["email"],
"type": "access"
}
if expires_delta: if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta expire = datetime.now(timezone.utc) + expires_delta
else: else:
expire = datetime.now(timezone.utc) + timedelta(minutes=30) expire = datetime.now(timezone.utc) + timedelta(minutes=30)
to_encode.update({"exp": expire, "type": "access"}) to_encode.update({
"exp": expire,
"iat": datetime.now(timezone.utc)
})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm) encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
logger.debug(f"Created access token for user {user_data['email']}")
return encoded_jwt return encoded_jwt
def create_refresh_token(self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: def create_refresh_token(self, user_data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT refresh token""" """Create JWT refresh token"""
to_encode = data.copy() to_encode = {
"sub": user_data["user_id"],
"user_id": user_data["user_id"],
"email": user_data["email"],
"type": "refresh"
}
if expires_delta: if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta expire = datetime.now(timezone.utc) + expires_delta
else: else:
expire = datetime.now(timezone.utc) + timedelta(days=7) expire = datetime.now(timezone.utc) + timedelta(days=7)
to_encode.update({"exp": expire, "type": "refresh"}) to_encode.update({
"exp": expire,
"iat": datetime.now(timezone.utc)
})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm) encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
return encoded_jwt return encoded_jwt
def verify_token(self, token: str) -> Optional[Dict[str, Any]]: def verify_token(self, token: str) -> Optional[Dict[str, Any]]:
"""Verify and decode JWT token""" """Verify and decode JWT token with comprehensive validation"""
try: try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm]) payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
# Validate required fields
required_fields = ["user_id", "email", "exp", "type"]
if not all(field in payload for field in required_fields):
logger.warning(f"Token missing required fields: {required_fields}")
return None
# Validate token type
if payload.get("type") not in ["access", "refresh"]:
logger.warning(f"Invalid token type: {payload.get('type')}")
return None
# Check expiration (jose handles this, but double-check)
exp = payload.get("exp")
if exp and datetime.fromtimestamp(exp, tz=timezone.utc) < datetime.now(timezone.utc):
logger.warning("Token has expired")
return None
return payload return payload
except jwt.ExpiredSignatureError:
logger.warning("Token has expired") except JWTError as e:
logger.warning(f"JWT validation failed: {e}")
return None return None
except jwt.JWTError: except Exception as e:
logger.warning("Invalid token") logger.error(f"Unexpected error validating token: {e}")
return None return None