diff --git a/services/auth/app/services/auth_service.py b/services/auth/app/services/auth_service.py index 9f48a23f..28bf6938 100644 --- a/services/auth/app/services/auth_service.py +++ b/services/auth/app/services/auth_service.py @@ -15,6 +15,7 @@ from app.schemas.auth import UserRegistration, UserLogin, TokenResponse, UserRes from app.models.users import User from app.models.tokens import RefreshToken from app.core.security import SecurityManager +from app.utils.subscription_fetcher import SubscriptionFetcher from shared.messaging import UnifiedEventPublisher, EVENT_TYPES from shared.database.unit_of_work import UnitOfWork from shared.database.transactions import transactional @@ -225,7 +226,7 @@ class EnhancedAuthService: } ) except Exception as e: - logger.warning("Failed to publish registration event", error=str(e)) + logger.warning("Failed to publish registration event: %s", str(e)) logger.info("User registered successfully using repository pattern", user_id=new_user.id, @@ -288,7 +289,28 @@ class EnhancedAuthService: logger.debug("Existing tokens revoked using repository pattern", user_id=user.id) + # NEW: Fetch subscription data for JWT enrichment + # This happens ONCE at login, not per-request + from app.core.config import settings + subscription_fetcher = SubscriptionFetcher( + tenant_service_url=settings.TENANT_SERVICE_URL # Now properly configurable + ) + + # Get service token for inter-service communication + service_token = await self._get_service_token() + + subscription_context = await subscription_fetcher.get_user_subscription_context( + user_id=str(user.id), + service_token=service_token + ) + + logger.debug("Fetched subscription context for JWT enrichment", + user_id=user.id, + subscription_tier=subscription_context.get("subscription", {}).get("tier")) + # Create tokens with different payloads + subscription_data = subscription_context.get("subscription") or {} + access_token_data = { "user_id": str(user.id), "email": user.email, @@ -296,7 +318,14 @@ class EnhancedAuthService: "is_verified": user.is_verified, "is_active": user.is_active, "role": user.role, - "type": "access" + "type": "access", + # NEW: Add subscription data to JWT payload + "tenant_id": subscription_context.get("tenant_id"), + "tenant_role": subscription_context.get("tenant_role"), + "subscription": subscription_data, + "subscription_tier": subscription_data.get("tier", "starter"), # Add direct field for gateway + "subscription_from_jwt": True, # Flag for gateway to use JWT data + "tenant_access": subscription_context.get("tenant_access") } refresh_token_data = { @@ -339,7 +368,7 @@ class EnhancedAuthService: } ) except Exception as e: - logger.warning("Failed to publish login event", error=str(e)) + logger.warning("Failed to publish login event: %s", str(e)) logger.info("User logged in successfully using repository pattern", user_id=user.id, @@ -425,7 +454,24 @@ class EnhancedAuthService: detail="User not found or inactive" ) - # Create new access token + # NEW: Fetch FRESH subscription data for token refresh + # This ensures subscription changes propagate within token expiry period + subscription_fetcher = SubscriptionFetcher( + tenant_service_url=settings.TENANT_SERVICE_URL # Now properly configurable + ) + + service_token = await self._get_service_token() + + subscription_context = await subscription_fetcher.get_user_subscription_context( + user_id=str(user.id), + service_token=service_token + ) + + logger.debug("Fetched fresh subscription context for token refresh", + user_id=user.id, + subscription_tier=subscription_context.get("subscription", {}).get("tier")) + + # Create new access token with updated subscription data access_token_data = { "user_id": str(user.id), "email": user.email, @@ -433,7 +479,12 @@ class EnhancedAuthService: "is_verified": user.is_verified, "is_active": user.is_active, "role": user.role, - "type": "access" + "type": "access", + # NEW: Add fresh subscription data to JWT payload + "tenant_id": subscription_context.get("tenant_id"), + "tenant_role": subscription_context.get("tenant_role"), + "subscription": subscription_context.get("subscription"), + "tenant_access": subscription_context.get("tenant_access") } new_access_token = SecurityManager.create_access_token(user_data=access_token_data) @@ -450,7 +501,7 @@ class EnhancedAuthService: except HTTPException: raise except Exception as e: - logger.error("Token refresh failed using repository pattern", error=str(e)) + logger.error("Token refresh failed using repository pattern: %s", str(e)) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token refresh failed" @@ -469,7 +520,7 @@ class EnhancedAuthService: return payload except Exception as e: - logger.error("Token verification error using repository pattern", error=str(e)) + logger.error("Token verification error using repository pattern: %s", str(e)) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" @@ -637,6 +688,24 @@ class EnhancedAuthService: user_id=user_id, error=str(e)) return False + + async def _get_service_token(self) -> str: + """ + Get service token for inter-service communication. + This is used to fetch subscription data from tenant service. + """ + try: + # Create a proper service token with JWT using SecurityManager + service_token = SecurityManager.create_service_token("auth-service") + + logger.debug("Generated service token for tenant service communication") + return service_token + except Exception as e: + logger.error(f"Failed to get service token: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to authenticate with tenant service" + ) # Legacy compatibility - alias EnhancedAuthService as AuthService diff --git a/services/auth/app/utils/subscription_fetcher.py b/services/auth/app/utils/subscription_fetcher.py new file mode 100644 index 00000000..75cf834f --- /dev/null +++ b/services/auth/app/utils/subscription_fetcher.py @@ -0,0 +1,144 @@ +"""Fetches subscription data for JWT enrichment at login time""" + +from typing import Dict, Any, Optional +import httpx +import logging +from fastapi import HTTPException, status + +logger = logging.getLogger(__name__) + + +class SubscriptionFetcher: + def __init__(self, tenant_service_url: str): + self.tenant_service_url = tenant_service_url.rstrip('/') + logger.info("SubscriptionFetcher initialized with URL: %s", self.tenant_service_url) + + async def get_user_subscription_context( + self, + user_id: str, + service_token: str + ) -> Dict[str, Any]: + """ + Fetch user's tenant memberships and subscription data. + Called ONCE at login, not per-request. + + Returns: + { + "tenant_id": "primary-tenant-uuid", + "tenant_role": "owner", + "subscription": { + "tier": "professional", + "status": "active", + "valid_until": "2025-02-15T00:00:00Z" + }, + "tenant_access": [ + {"id": "uuid", "role": "admin", "tier": "starter"} + ] + } + """ + try: + logger.debug("Fetching subscription data for user: %s", user_id) + + async with httpx.AsyncClient(timeout=10.0) as client: + # Get user's tenant memberships - corrected URL + memberships_url = f"{self.tenant_service_url}/api/v1/tenants/members/user/{user_id}" + headers = { + "Authorization": f"Bearer {service_token}", + "Content-Type": "application/json" + } + + logger.debug("Fetching user memberships from URL: %s", memberships_url) + response = await client.get(memberships_url, headers=headers) + + if response.status_code != 200: + logger.error(f"Failed to fetch user memberships: {response.status_code}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to fetch user memberships" + ) + + memberships = response.json() + + if not memberships: + logger.info(f"User {user_id} has no tenant memberships - returning default subscription context") + return { + "tenant_id": None, + "tenant_role": None, + "subscription": { + "tier": "starter", + "status": "active", + "valid_until": None + }, + "tenant_access": [] + } + + # Get primary tenant (first one, or the one with highest role) + primary_membership = memberships[0] + for membership in memberships: + if membership.get("role") == "owner": + primary_membership = membership + break + + primary_tenant_id = primary_membership["tenant_id"] + primary_role = primary_membership["role"] + + # Get subscription for primary tenant - FIXED: Use correct endpoint + subscription_url = f"{self.tenant_service_url}/api/v1/tenants/subscriptions/{primary_tenant_id}/active" + subscription_response = await client.get(subscription_url, headers=headers) + + if subscription_response.status_code != 200: + logger.error(f"Failed to fetch subscription for tenant {primary_tenant_id}: {subscription_response.status_code}") + # Return with basic info but no subscription + return { + "tenant_id": primary_tenant_id, + "tenant_role": primary_role, + "subscription": None, + "tenant_access": memberships + } + + subscription_data = subscription_response.json() + + # Build tenant access list with subscription info + tenant_access = [] + for membership in memberships: + tenant_id = membership["tenant_id"] + role = membership["role"] + + # Get subscription for each tenant - FIXED: Use correct endpoint + tenant_sub_url = f"{self.tenant_service_url}/api/v1/tenants/subscriptions/{tenant_id}/active" + tenant_sub_response = await client.get(tenant_sub_url, headers=headers) + + tier = "starter" # default + if tenant_sub_response.status_code == 200: + tenant_sub = tenant_sub_response.json() + tier = tenant_sub.get("plan", "starter") + + tenant_access.append({ + "id": tenant_id, + "role": role, + "tier": tier + }) + + return { + "tenant_id": primary_tenant_id, + "tenant_role": primary_role, + "subscription": { + "tier": subscription_data.get("plan", "starter"), + "status": subscription_data.get("status", "active"), + "valid_until": subscription_data.get("valid_until", None) + }, + "tenant_access": tenant_access + } + + except httpx.HTTPError as e: + logger.error(f"HTTP error fetching subscription data: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"HTTP error fetching subscription data: {str(e)}" + ) + except Exception as e: + logger.error(f"Error fetching subscription data: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error fetching subscription data: {str(e)}" + ) \ No newline at end of file