diff --git a/gateway/app/routes/tenant.py b/gateway/app/routes/tenant.py index ef36ec87..c38177cb 100644 --- a/gateway/app/routes/tenant.py +++ b/gateway/app/routes/tenant.py @@ -1,5 +1,5 @@ """ -Tenant routes for gateway +Tenant routes for gateway - FIXED VERSION """ from fastapi import APIRouter, Request, HTTPException @@ -17,16 +17,37 @@ async def create_tenant(request: Request): """Proxy tenant creation to tenant service""" try: body = await request.body() - auth_header = request.headers.get("Authorization") + + # ✅ FIX: Forward all headers AND add user context from gateway auth + headers = dict(request.headers) + headers.pop("host", None) # Remove host header + + # ✅ ADD USER CONTEXT FROM GATEWAY AUTHENTICATION + # Gateway middleware already verified the token and added user to request.state + if hasattr(request.state, 'user'): + headers["X-User-ID"] = str(request.state.user.get("user_id")) + headers["X-User-Email"] = request.state.user.get("email", "") + headers["X-User-Role"] = request.state.user.get("role", "user") + + # Add tenant ID if it exists + if hasattr(request.state, 'tenant_id') and request.state.tenant_id: + headers["X-Tenant-ID"] = str(request.state.tenant_id) + elif request.state.user.get("tenant_id"): + headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id")) + + roles = request.state.user.get("roles", []) + if roles: + headers["X-User-Roles"] = ",".join(roles) + + permissions = request.state.user.get("permissions", []) + if permissions: + headers["X-User-Permissions"] = ",".join(permissions) async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/register", content=body, - headers={ - "Content-Type": "application/json", - "Authorization": auth_header - } + headers=headers ) return JSONResponse( @@ -45,12 +66,81 @@ async def create_tenant(request: Request): async def get_tenants(request: Request): """Get tenants""" try: - auth_header = request.headers.get("Authorization") + # ✅ FIX: Same pattern for GET requests + headers = dict(request.headers) + headers.pop("host", None) + + # Add user context from gateway auth + if hasattr(request.state, 'user'): + headers["X-User-ID"] = str(request.state.user.get("user_id")) + headers["X-User-Email"] = request.state.user.get("email", "") + headers["X-User-Role"] = request.state.user.get("role", "user") + + if hasattr(request.state, 'tenant_id') and request.state.tenant_id: + headers["X-Tenant-ID"] = str(request.state.tenant_id) + elif request.state.user.get("tenant_id"): + headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id")) + + roles = request.state.user.get("roles", []) + if roles: + headers["X-User-Roles"] = ",".join(roles) async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get( - f"{settings.TENANT_SERVICE_URL}/tenants", - headers={"Authorization": auth_header} + f"{settings.TENANT_SERVICE_URL}/api/v1/tenants", + headers=headers + ) + + return JSONResponse( + status_code=response.status_code, + content=response.json() + ) + + except httpx.RequestError as e: + logger.error(f"Tenant service unavailable: {e}") + raise HTTPException( + status_code=503, + detail="Tenant service unavailable" + ) + +# ✅ ADD: Generic proxy function like the data service has +async def _proxy_tenant_request(request: Request, target_path: str, method: str = None): + """Proxy request to tenant service with user context""" + try: + url = f"{settings.TENANT_SERVICE_URL}{target_path}" + + # Forward headers with user context + headers = dict(request.headers) + headers.pop("host", None) + + # Add user context from gateway authentication + if hasattr(request.state, 'user'): + headers["X-User-ID"] = str(request.state.user.get("user_id")) + headers["X-User-Email"] = request.state.user.get("email", "") + headers["X-User-Role"] = request.state.user.get("role", "user") + + if hasattr(request.state, 'tenant_id') and request.state.tenant_id: + headers["X-Tenant-ID"] = str(request.state.tenant_id) + elif request.state.user.get("tenant_id"): + headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id")) + + roles = request.state.user.get("roles", []) + if roles: + headers["X-User-Roles"] = ",".join(roles) + + # Get request body if present + body = None + request_method = method or request.method + if request_method in ["POST", "PUT", "PATCH"]: + body = await request.body() + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.request( + method=request_method, + url=url, + headers=headers, + content=body, + params=dict(request.query_params) ) return JSONResponse( diff --git a/scripts/test_unified_auth.sh b/scripts/test_unified_auth.sh index 68f446f5..d08b8247 100755 --- a/scripts/test_unified_auth.sh +++ b/scripts/test_unified_auth.sh @@ -1,7 +1,7 @@ #!/bin/bash # ================================================================ -# Complete Authentication Test with Registration +# Complete Authentication Test with Registration - FIXED VERSION # Tests the full user lifecycle: registration → login → API access # ================================================================ @@ -14,7 +14,8 @@ AUTH_BASE="$API_BASE/api/v1/auth" TEST_EMAIL="test-$(date +%s)@bakery.com" # Unique email for each test TEST_PASSWORD="SecurePass123!" TEST_NAME="Test Baker" -TENANT_ID="test-tenant-$(date +%s)" +# ✅ FIX: Generate a proper UUID for tenant testing (will be replaced after bakery creation) +TENANT_ID=$(uuidgen 2>/dev/null || python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null || echo "00000000-0000-0000-0000-000000000000") # Colors for output RED='\033[0;31m' @@ -73,6 +74,12 @@ if ! check_service_health "http://localhost:8001" "Auth Service"; then exit 1 fi +# Check Tenant Service +if ! check_service_health "http://localhost:8005" "Tenant Service"; then + log_error "Tenant Service is not running. Check: docker-compose logs tenant-service" + exit 1 +fi + # Check Data Service if ! check_service_health "http://localhost:8004" "Data Service"; then log_warning "Data Service is not running, but continuing with auth tests..." @@ -149,13 +156,13 @@ fi echo "" # ================================================================ -# STEP 4: ACCESSING PROTECTED ENDPOINTS +# STEP 3: ACCESSING PROTECTED ENDPOINTS # ================================================================ -log_step "Step 4: Testing protected endpoints with authentication" +log_step "Step 3: Testing protected endpoints with authentication" -# 4a. Get current user info -log_step "4a. Getting current user profile" +# 3a. Get current user info +log_step "3a. Getting current user profile" USER_PROFILE_RESPONSE=$(curl -s -X GET "$API_BASE/api/v1/users/me" \ -H "Authorization: Bearer $ACCESS_TOKEN") @@ -171,53 +178,11 @@ fi echo "" -# 4b. Test data service through gateway -log_step "4b. Testing data service through gateway" - -DATA_RESPONSE=$(curl -s -X GET "$API_BASE/api/v1/data/sales" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "X-Tenant-ID: $TENANT_ID") - -echo "Data Service Response:" -echo "$DATA_RESPONSE" | jq '.' - -if [ "$(echo "$DATA_RESPONSE" | jq -r '.status // "unknown"')" != "error" ]; then - log_success "Data service access successful!" -else - log_warning "Data service returned error (may be expected for new tenant)" -fi - -echo "" - -# 4c. Test training service through gateway -log_step "4c. Testing training service through gateway" - -TRAINING_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/training/jobs" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "X-Tenant-ID: $TENANT_ID" \ - -H "Content-Type: application/json" \ - -d '{ - "include_weather": true, - "include_traffic": false, - "min_data_points": 30 - }') - -echo "Training Service Response:" -echo "$TRAINING_RESPONSE" | jq '.' - -if echo "$TRAINING_RESPONSE" | jq -e '.job_id // .message' > /dev/null; then - log_success "Training service access successful!" -else - log_warning "Training service access may have issues" -fi - -echo "" - # ================================================================ -# STEP 5: TENANT REGISTRATION (OPTIONAL) +# STEP 4: TENANT REGISTRATION (BAKERY CREATION) # ================================================================ -log_step "Step 5: Registering a bakery/tenant" +log_step "Step 4: Registering a bakery/tenant" BAKERY_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/tenants/register" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ @@ -235,19 +200,80 @@ echo "Bakery Registration Response:" echo "$BAKERY_RESPONSE" | jq '.' if echo "$BAKERY_RESPONSE" | jq -e '.id' > /dev/null; then + # ✅ FIX: Use the actual tenant ID returned from bakery creation TENANT_ID=$(echo "$BAKERY_RESPONSE" | jq -r '.id') log_success "Bakery registration successful! Tenant ID: $TENANT_ID" else - log_warning "Bakery registration endpoint may not be fully implemented" + log_error "Bakery registration failed!" + echo "Response: $BAKERY_RESPONSE" + # Continue with tests using placeholder UUID for other endpoints fi echo "" # ================================================================ -# STEP 6: TOKEN REFRESH +# STEP 5: TEST DATA SERVICE WITH TENANT ID # ================================================================ -log_step "Step 6: Testing token refresh" +log_step "Step 5: Testing data service through gateway" + +# Only test with valid tenant ID +if [ "$TENANT_ID" != "00000000-0000-0000-0000-000000000000" ]; then + DATA_RESPONSE=$(curl -s -X GET "$API_BASE/api/v1/data/sales" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "X-Tenant-ID: $TENANT_ID") + + echo "Data Service Response:" + echo "$DATA_RESPONSE" | jq '.' + + if [ "$(echo "$DATA_RESPONSE" | jq -r '.status // "unknown"')" != "error" ]; then + log_success "Data service access successful!" + else + log_warning "Data service returned error (may be expected for new tenant)" + fi +else + log_warning "Skipping data service test - no valid tenant ID" +fi + +echo "" + +# ================================================================ +# STEP 6: TEST TRAINING SERVICE WITH TENANT ID +# ================================================================ + +log_step "Step 6: Testing training service through gateway" + +# Only test with valid tenant ID +if [ "$TENANT_ID" != "00000000-0000-0000-0000-000000000000" ]; then + TRAINING_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/training/jobs" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "X-Tenant-ID: $TENANT_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "include_weather": true, + "include_traffic": false, + "min_data_points": 30 + }') + + echo "Training Service Response:" + echo "$TRAINING_RESPONSE" | jq '.' + + if echo "$TRAINING_RESPONSE" | jq -e '.job_id // .message' > /dev/null; then + log_success "Training service access successful!" + else + log_warning "Training service access may have issues" + fi +else + log_warning "Skipping training service test - no valid tenant ID" +fi + +echo "" + +# ================================================================ +# STEP 7: TOKEN REFRESH +# ================================================================ + +log_step "Step 7: Testing token refresh" REFRESH_RESPONSE=$(curl -s -X POST "$AUTH_BASE/refresh" \ -H "Content-Type: application/json" \ @@ -268,19 +294,19 @@ fi echo "" # ================================================================ -# STEP 7: DIRECT SERVICE HEALTH CHECKS +# STEP 8: DIRECT SERVICE HEALTH CHECKS # ================================================================ -log_step "Step 7: Testing direct service access (without gateway)" +log_step "Step 8: Testing direct service access (without gateway)" # Test auth service directly -log_step "7a. Auth service direct health check" +log_step "8a. Auth service direct health check" AUTH_HEALTH=$(curl -s -X GET "http://localhost:8001/health") echo "Auth Service Health:" echo "$AUTH_HEALTH" | jq '.' # Test other services if available -log_step "7b. Other services health check" +log_step "8b. Other services health check" services=("8002:Training" "8003:Forecasting" "8004:Data" "8005:Tenant" "8006:Notification") @@ -299,10 +325,10 @@ done echo "" # ================================================================ -# STEP 8: LOGOUT +# STEP 9: LOGOUT # ================================================================ -log_step "Step 8: Logging out user" +log_step "Step 9: Logging out user" LOGOUT_RESPONSE=$(curl -s -X POST "$AUTH_BASE/logout" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ @@ -342,12 +368,12 @@ echo "" echo "Services Tested:" echo " 🌐 API Gateway" echo " 🔐 Auth Service" +echo " 🏢 Tenant Service (bakery registration)" echo " 📊 Data Service (through gateway)" echo " 🤖 Training Service (through gateway)" -echo " 🏢 Tenant Service (bakery registration)" echo "" -if [ -n "$TENANT_ID" ]; then +if [ "$TENANT_ID" != "00000000-0000-0000-0000-000000000000" ]; then echo "Tenant Created:" echo " 🏪 Tenant ID: $TENANT_ID" echo "" diff --git a/services/auth/app/api/auth.py b/services/auth/app/api/auth.py index a6fa8f7d..f50ba046 100644 --- a/services/auth/app/api/auth.py +++ b/services/auth/app/api/auth.py @@ -103,8 +103,14 @@ async def login( metrics = get_metrics_collector(request) try: - # Check login attempts - + # Check login attempts TODO + # if not await SecurityManager.check_login_attempts(login_data.email): + # if metrics: + # metrics.increment_counter("login_failure_total", labels={"reason": "rate_limited"}) + # raise HTTPException( + # status_code=status.HTTP_429_TOO_MANY_REQUESTS, + # detail="Too many login attempts. Please try again later." + # ) # Attempt login result = await AuthService.login(login_data.email, login_data.password, db) diff --git a/services/tenant/app/main.py b/services/tenant/app/main.py index 7828239a..a79dd3ba 100644 --- a/services/tenant/app/main.py +++ b/services/tenant/app/main.py @@ -1,6 +1,6 @@ # services/tenant/app/main.py """ -Tenant Service FastAPI application +Tenant Service FastAPI application - FIXED VERSION """ import structlog @@ -47,11 +47,35 @@ async def startup_event(): """Initialize service on startup""" logger.info("Starting Tenant Service...") + try: + # ✅ FIX: Import models to ensure they're registered with SQLAlchemy + from app.models.tenants import Tenant, TenantMember, Subscription + logger.info("Tenant models imported successfully") + + # ✅ FIX: Create database tables on startup + await database_manager.create_tables() + logger.info("Tenant database tables created successfully") + + except Exception as e: + logger.error(f"Failed to initialize tenant service: {e}") + raise + + logger.info("Tenant Service startup completed successfully") + @app.on_event("shutdown") async def shutdown_event(): """Cleanup on shutdown""" logger.info("Shutting down Tenant Service...") - await database_manager.engine.dispose() + + try: + # Close database connections properly + if hasattr(database_manager, 'engine') and database_manager.engine: + await database_manager.engine.dispose() + logger.info("Database connections closed") + except Exception as e: + logger.error(f"Error during shutdown: {e}") + + logger.info("Tenant Service shutdown completed") @app.get("/health") async def health_check(): diff --git a/services/tenant/app/schemas/tenants.py b/services/tenant/app/schemas/tenants.py index 592f4f70..8436a916 100644 --- a/services/tenant/app/schemas/tenants.py +++ b/services/tenant/app/schemas/tenants.py @@ -1,11 +1,12 @@ # services/tenant/app/schemas/tenants.py """ -Tenant schemas +Tenant schemas - FIXED VERSION """ from pydantic import BaseModel, Field, validator from typing import Optional, List, Dict, Any from datetime import datetime +from uuid import UUID import re class BakeryRegistration(BaseModel): @@ -42,8 +43,8 @@ class BakeryRegistration(BaseModel): return v class TenantResponse(BaseModel): - """Tenant response schema""" - id: str + """Tenant response schema - FIXED VERSION""" + id: str # ✅ Keep as str for Pydantic validation name: str subdomain: Optional[str] business_type: str @@ -55,9 +56,17 @@ class TenantResponse(BaseModel): subscription_tier: str model_trained: bool last_training_date: Optional[datetime] - owner_id: str + owner_id: str # ✅ Keep as str for Pydantic validation created_at: datetime + # ✅ FIX: Add custom validator to convert UUID to string + @validator('id', 'owner_id', pre=True) + def convert_uuid_to_string(cls, v): + """Convert UUID objects to strings for JSON serialization""" + if isinstance(v, UUID): + return str(v) + return v + class Config: from_attributes = True @@ -68,16 +77,70 @@ class TenantAccessResponse(BaseModel): permissions: List[str] class TenantMemberResponse(BaseModel): - """Tenant member response""" + """Tenant member response - FIXED VERSION""" id: str user_id: str role: str is_active: bool joined_at: Optional[datetime] + # ✅ FIX: Add custom validator to convert UUID to string + @validator('id', 'user_id', pre=True) + def convert_uuid_to_string(cls, v): + """Convert UUID objects to strings for JSON serialization""" + if isinstance(v, UUID): + return str(v) + return v + + class Config: + from_attributes = True + 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 \ No newline at end of file + business_type: Optional[str] = None + +class TenantListResponse(BaseModel): + """Response schema for listing tenants""" + tenants: List[TenantResponse] + total: int + page: int + per_page: int + has_next: bool + has_prev: bool + +class TenantMemberInvitation(BaseModel): + """Schema for inviting a member to a tenant""" + email: str = Field(..., pattern=r'^[^@]+@[^@]+\.[^@]+$') + role: str = Field(..., pattern=r'^(admin|member|viewer)$') + message: Optional[str] = Field(None, max_length=500) + +class TenantMemberUpdate(BaseModel): + """Schema for updating tenant member""" + role: Optional[str] = Field(None, pattern=r'^(owner|admin|member|viewer)$') + is_active: Optional[bool] = None + +class TenantSubscriptionUpdate(BaseModel): + """Schema for updating tenant subscription""" + plan: str = Field(..., pattern=r'^(basic|professional|enterprise)$') + billing_cycle: str = Field(default="monthly", pattern=r'^(monthly|yearly)$') + +class TenantStatsResponse(BaseModel): + """Tenant statistics response""" + tenant_id: str + total_members: int + active_members: int + total_predictions: int + models_trained: int + last_training_date: Optional[datetime] + subscription_plan: str + subscription_status: str + + @validator('tenant_id', pre=True) + def convert_uuid_to_string(cls, v): + """Convert UUID objects to strings for JSON serialization""" + if isinstance(v, UUID): + return str(v) + return v \ No newline at end of file diff --git a/services/tenant/app/services/messaging.py b/services/tenant/app/services/messaging.py index c73d7a55..850b0422 100644 --- a/services/tenant/app/services/messaging.py +++ b/services/tenant/app/services/messaging.py @@ -5,6 +5,7 @@ Tenant service messaging for event publishing from shared.messaging.rabbitmq import RabbitMQClient from app.core.config import settings import structlog +from datetime import datetime logger = structlog.get_logger()