Add subcription feature 6
This commit is contained in:
@@ -104,7 +104,7 @@ async def request_account_deletion(
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
cancel_response = await client.get(
|
||||
f"http://tenant-service:8000/api/v1/subscriptions/{tenant_id}/status",
|
||||
f"http://tenant-service:8000/api/v1/tenants/{tenant_id}/subscription/status",
|
||||
headers={"Authorization": request.headers.get("Authorization")}
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from shared.exceptions.auth_exceptions import (
|
||||
RegistrationError,
|
||||
PaymentOrchestrationError
|
||||
)
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -291,8 +292,15 @@ async def login(
|
||||
|
||||
logger.info(f"Login successful, email={login_data.email}, user_id={result['user'].id}")
|
||||
|
||||
# Extract tokens from result for top-level response
|
||||
tokens = result.get('tokens', {})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"access_token": tokens.get('access_token'),
|
||||
"refresh_token": tokens.get('refresh_token'),
|
||||
"token_type": tokens.get('token_type'),
|
||||
"expires_in": tokens.get('expires_in'),
|
||||
"user": {
|
||||
"id": str(result['user'].id),
|
||||
"email": result['user'].email,
|
||||
@@ -300,7 +308,6 @@ async def login(
|
||||
"is_active": result['user'].is_active,
|
||||
"last_login": result['user'].last_login.isoformat() if result['user'].last_login else None
|
||||
},
|
||||
"tokens": result.get('tokens', {}),
|
||||
"subscription": result.get('subscription', {}),
|
||||
"message": "Login successful"
|
||||
}
|
||||
@@ -317,3 +324,309 @@ async def login(
|
||||
) from e
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TOKEN MANAGEMENT ENDPOINTS - NEWLY ADDED
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/refresh",
|
||||
response_model=Dict[str, Any],
|
||||
summary="Refresh access token using refresh token")
|
||||
async def refresh_token(
|
||||
request: Request,
|
||||
refresh_data: Dict[str, Any],
|
||||
auth_service: AuthService = Depends(get_auth_service)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Refresh access token using a valid refresh token
|
||||
|
||||
This endpoint:
|
||||
1. Validates the refresh token
|
||||
2. Generates new access and refresh tokens
|
||||
3. Returns the new tokens
|
||||
|
||||
Args:
|
||||
refresh_data: Dictionary containing refresh_token
|
||||
|
||||
Returns:
|
||||
New authentication tokens
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 for invalid refresh tokens
|
||||
"""
|
||||
try:
|
||||
logger.info("Token refresh request initiated")
|
||||
|
||||
# Extract refresh token from request
|
||||
refresh_token = refresh_data.get("refresh_token")
|
||||
if not refresh_token:
|
||||
logger.warning("Refresh token missing from request")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Refresh token is required"
|
||||
)
|
||||
|
||||
# Use service layer to refresh tokens
|
||||
tokens = await auth_service.refresh_auth_tokens(refresh_token)
|
||||
|
||||
logger.info("Token refresh successful via service layer")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"access_token": tokens.get("access_token"),
|
||||
"refresh_token": tokens.get("refresh_token"),
|
||||
"token_type": "bearer",
|
||||
"expires_in": 1800, # 30 minutes
|
||||
"message": "Token refresh successful"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Token refresh failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/verify",
|
||||
response_model=Dict[str, Any],
|
||||
summary="Verify token validity")
|
||||
async def verify_token(
|
||||
request: Request,
|
||||
token_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify the validity of an access token
|
||||
|
||||
Args:
|
||||
token_data: Dictionary containing access_token
|
||||
|
||||
Returns:
|
||||
Token validation result
|
||||
"""
|
||||
try:
|
||||
logger.info("Token verification request initiated")
|
||||
|
||||
# Extract access token from request
|
||||
access_token = token_data.get("access_token")
|
||||
if not access_token:
|
||||
logger.warning("Access token missing from verification request")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Access token is required"
|
||||
)
|
||||
|
||||
# Use service layer to verify token
|
||||
result = await auth_service.verify_access_token(access_token)
|
||||
|
||||
logger.info("Token verification successful via service layer")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"valid": result.get("valid"),
|
||||
"user_id": result.get("user_id"),
|
||||
"email": result.get("email"),
|
||||
"message": "Token is valid"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Token verification failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/logout",
|
||||
response_model=Dict[str, Any],
|
||||
summary="Logout and revoke refresh token")
|
||||
async def logout(
|
||||
request: Request,
|
||||
logout_data: Dict[str, Any],
|
||||
auth_service: AuthService = Depends(get_auth_service)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Logout user and revoke refresh token
|
||||
|
||||
Args:
|
||||
logout_data: Dictionary containing refresh_token
|
||||
|
||||
Returns:
|
||||
Logout confirmation
|
||||
"""
|
||||
try:
|
||||
logger.info("Logout request initiated")
|
||||
|
||||
# Extract refresh token from request
|
||||
refresh_token = logout_data.get("refresh_token")
|
||||
if not refresh_token:
|
||||
logger.warning("Refresh token missing from logout request")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Refresh token is required"
|
||||
)
|
||||
|
||||
# Use service layer to revoke refresh token
|
||||
try:
|
||||
await auth_service.revoke_refresh_token(refresh_token)
|
||||
logger.info("Logout successful via service layer")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Logout successful"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during logout: {str(e)}")
|
||||
# Don't fail logout if revocation fails
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Logout successful (token revocation failed but user logged out)"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Logout failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Logout failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/change-password",
|
||||
response_model=Dict[str, Any],
|
||||
summary="Change user password")
|
||||
async def change_password(
|
||||
request: Request,
|
||||
password_data: Dict[str, Any],
|
||||
auth_service: AuthService = Depends(get_auth_service)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Change user password
|
||||
|
||||
Args:
|
||||
password_data: Dictionary containing current_password and new_password
|
||||
|
||||
Returns:
|
||||
Password change confirmation
|
||||
"""
|
||||
try:
|
||||
logger.info("Password change request initiated")
|
||||
|
||||
# Extract user from request state
|
||||
if not hasattr(request.state, 'user') or not request.state.user:
|
||||
logger.warning("Unauthorized password change attempt - no user context")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required"
|
||||
)
|
||||
|
||||
user_id = request.state.user.get("user_id")
|
||||
if not user_id:
|
||||
logger.warning("Unauthorized password change attempt - no user_id")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid user context"
|
||||
)
|
||||
|
||||
# Extract password data
|
||||
current_password = password_data.get("current_password")
|
||||
new_password = password_data.get("new_password")
|
||||
|
||||
if not current_password or not new_password:
|
||||
logger.warning("Password change missing required fields")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Current password and new password are required"
|
||||
)
|
||||
|
||||
if len(new_password) < 8:
|
||||
logger.warning("New password too short")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="New password must be at least 8 characters long"
|
||||
)
|
||||
|
||||
# Use service layer to change password
|
||||
await auth_service.change_user_password(user_id, current_password, new_password)
|
||||
|
||||
logger.info(f"Password change successful via service layer, user_id={user_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Password changed successfully"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Password change failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Password change failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/verify-email",
|
||||
response_model=Dict[str, Any],
|
||||
summary="Verify user email")
|
||||
async def verify_email(
|
||||
request: Request,
|
||||
email_data: Dict[str, Any],
|
||||
auth_service: AuthService = Depends(get_auth_service)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify user email (placeholder implementation)
|
||||
|
||||
Args:
|
||||
email_data: Dictionary containing email and verification_token
|
||||
|
||||
Returns:
|
||||
Email verification confirmation
|
||||
"""
|
||||
try:
|
||||
logger.info("Email verification request initiated")
|
||||
|
||||
# Extract email and token
|
||||
email = email_data.get("email")
|
||||
verification_token = email_data.get("verification_token")
|
||||
|
||||
if not email or not verification_token:
|
||||
logger.warning("Email verification missing required fields")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email and verification token are required"
|
||||
)
|
||||
|
||||
# Use service layer to verify email
|
||||
await auth_service.verify_user_email(email, verification_token)
|
||||
|
||||
logger.info("Email verification successful via service layer")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Email verified successfully"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Email verification failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Email verification failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
|
||||
@@ -238,6 +238,116 @@ async def get_user_by_id(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/api/v1/auth/users/{user_id}", response_model=UserResponse)
|
||||
async def update_user_profile(
|
||||
user_id: str = Path(..., description="User ID"),
|
||||
update_data: UserUpdate = Body(..., description="User profile update data"),
|
||||
current_user = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update user profile information.
|
||||
|
||||
This endpoint allows users to update their profile information including:
|
||||
- Full name
|
||||
- Phone number
|
||||
- Language preference
|
||||
- Timezone
|
||||
|
||||
**Permissions:** Users can update their own profile, admins can update any user's profile
|
||||
"""
|
||||
try:
|
||||
# Validate UUID format
|
||||
try:
|
||||
uuid.UUID(user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid user ID format"
|
||||
)
|
||||
|
||||
# Check permissions - user can update their own profile, admins can update any
|
||||
if current_user["user_id"] != user_id:
|
||||
# Check if current user has admin privileges
|
||||
user_role = current_user.get("role", "user")
|
||||
if user_role not in ["admin", "super_admin", "manager"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions to update this user's profile"
|
||||
)
|
||||
|
||||
# Fetch user from database
|
||||
from app.repositories import UserRepository
|
||||
user_repo = UserRepository(User, db)
|
||||
user = await user_repo.get_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {user_id} not found"
|
||||
)
|
||||
|
||||
# Prepare update data (only include fields that are provided)
|
||||
update_fields = update_data.dict(exclude_unset=True)
|
||||
if not update_fields:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No update data provided"
|
||||
)
|
||||
|
||||
# Update user
|
||||
updated_user = await user_repo.update(user_id, update_fields)
|
||||
|
||||
logger.info("User profile updated", user_id=user_id, updated_fields=list(update_fields.keys()))
|
||||
|
||||
# Log audit event for user profile update
|
||||
try:
|
||||
# Get tenant_id from current_user or use a placeholder for system-level operations
|
||||
tenant_id_str = current_user.get("tenant_id", "00000000-0000-0000-0000-000000000000")
|
||||
await audit_logger.log_event(
|
||||
db_session=db,
|
||||
tenant_id=tenant_id_str,
|
||||
user_id=current_user["user_id"],
|
||||
action=AuditAction.UPDATE.value,
|
||||
resource_type="user",
|
||||
resource_id=user_id,
|
||||
severity=AuditSeverity.MEDIUM.value,
|
||||
description=f"User {current_user.get('email', current_user['user_id'])} updated profile for user {user.email}",
|
||||
changes={"updated_fields": list(update_fields.keys())},
|
||||
audit_metadata={"updated_data": update_fields},
|
||||
endpoint="/users/{user_id}",
|
||||
method="PUT"
|
||||
)
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to log audit event", error=str(audit_error))
|
||||
|
||||
return UserResponse(
|
||||
id=str(updated_user.id),
|
||||
email=updated_user.email,
|
||||
full_name=updated_user.full_name,
|
||||
is_active=updated_user.is_active,
|
||||
is_verified=updated_user.is_verified,
|
||||
phone=updated_user.phone,
|
||||
language=updated_user.language or "es",
|
||||
timezone=updated_user.timezone or "Europe/Madrid",
|
||||
created_at=updated_user.created_at,
|
||||
last_login=updated_user.last_login,
|
||||
role=updated_user.role,
|
||||
tenant_id=None,
|
||||
payment_customer_id=updated_user.payment_customer_id,
|
||||
default_payment_method_id=updated_user.default_payment_method_id
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Update user profile error", user_id=user_id, error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update user profile"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/users/create-by-owner", response_model=UserResponse)
|
||||
async def create_user_by_owner(
|
||||
user_data: OwnerUserCreate,
|
||||
@@ -524,21 +634,20 @@ async def update_user_tenant(
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Update user's tenant_id
|
||||
user.tenant_id = uuid.UUID(tenant_id)
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info("Successfully updated user tenant_id",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id)
|
||||
# DEPRECATED: User-tenant relationships are now managed by tenant service
|
||||
# This endpoint is kept for backward compatibility but does nothing
|
||||
# The tenant service should manage user-tenant relationships internally
|
||||
|
||||
logger.warning("DEPRECATED: update_user_tenant endpoint called - user-tenant relationships are now managed by tenant service",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id)
|
||||
|
||||
# Return success for backward compatibility, but don't actually update anything
|
||||
return {
|
||||
"success": True,
|
||||
"user_id": str(user.id),
|
||||
"tenant_id": str(user.tenant_id)
|
||||
"tenant_id": tenant_id,
|
||||
"message": "User-tenant relationships are now managed by tenant service. This endpoint is deprecated."
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
|
||||
@@ -69,7 +69,7 @@ async def get_current_user(
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
logger.info(f"User authenticated: {user.email} (tenant: {user.tenant_id})")
|
||||
logger.info(f"User authenticated: {user.email}")
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -15,10 +15,33 @@ class AuthService(StandardFastAPIService):
|
||||
"""Authentication Service with standardized setup"""
|
||||
|
||||
async def on_startup(self, app):
|
||||
"""Custom startup logic including migration verification"""
|
||||
"""Custom startup logic including migration verification and Redis initialization"""
|
||||
self.logger.info("Starting auth service on_startup")
|
||||
await self.verify_migrations()
|
||||
|
||||
# Initialize Redis if not already done during service creation
|
||||
if not self.redis_initialized:
|
||||
try:
|
||||
from shared.redis_utils import initialize_redis, get_redis_client
|
||||
await initialize_redis(settings.REDIS_URL_WITH_DB, db=settings.REDIS_DB, max_connections=getattr(settings, 'REDIS_MAX_CONNECTIONS', 50))
|
||||
self.redis_client = await get_redis_client()
|
||||
self.redis_initialized = True
|
||||
self.logger.info("Connected to Redis for token management")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to connect to Redis during startup: {e}")
|
||||
raise
|
||||
|
||||
await super().on_startup(app)
|
||||
|
||||
async def on_shutdown(self, app):
|
||||
"""Custom shutdown logic for Auth Service"""
|
||||
await super().on_shutdown(app)
|
||||
|
||||
# Close Redis
|
||||
from shared.redis_utils import close_redis
|
||||
await close_redis()
|
||||
self.logger.info("Redis connection closed")
|
||||
|
||||
async def verify_migrations(self):
|
||||
"""Verify database schema matches the latest migrations."""
|
||||
try:
|
||||
@@ -47,6 +70,35 @@ class AuthService(StandardFastAPIService):
|
||||
self.logger.warning(f"Migration verification failed (this may be expected during initial setup): {e}")
|
||||
|
||||
def __init__(self):
|
||||
# Initialize Redis during service creation so it's available when needed
|
||||
try:
|
||||
import asyncio
|
||||
# We need to run the async initialization in a sync context
|
||||
try:
|
||||
# Check if there's already a running event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
# If there is, we'll initialize Redis later in on_startup
|
||||
self.redis_initialized = False
|
||||
self.redis_client = None
|
||||
except RuntimeError:
|
||||
# No event loop running, safe to run the async function
|
||||
import asyncio
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply() # Allow nested event loops
|
||||
|
||||
async def init_redis():
|
||||
from shared.redis_utils import initialize_redis, get_redis_client
|
||||
await initialize_redis(settings.REDIS_URL_WITH_DB, db=settings.REDIS_DB, max_connections=getattr(settings, 'REDIS_MAX_CONNECTIONS', 50))
|
||||
return await get_redis_client()
|
||||
|
||||
self.redis_client = asyncio.run(init_redis())
|
||||
self.redis_initialized = True
|
||||
self.logger.info("Connected to Redis for token management")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to initialize Redis during service creation: {e}")
|
||||
self.redis_initialized = False
|
||||
self.redis_client = None
|
||||
|
||||
# Define expected database tables for health checks
|
||||
auth_expected_tables = [
|
||||
'users', 'refresh_tokens', 'user_onboarding_progress',
|
||||
|
||||
@@ -140,15 +140,7 @@ class UserResponse(BaseModel):
|
||||
from_attributes = True # ✅ Enable ORM mode for SQLAlchemy objects
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""User update schema"""
|
||||
full_name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TokenVerification(BaseModel):
|
||||
"""Token verification response"""
|
||||
|
||||
@@ -454,28 +454,33 @@ class AuthService:
|
||||
try:
|
||||
logger.info(f"Validating user subscription, user_id={user.id}, email={user.email}")
|
||||
|
||||
# Check if user has a tenant_id (indicates they should have a subscription)
|
||||
if user.tenant_id:
|
||||
logger.info(f"User has tenant - subscription validation required, user_id={user.id}, tenant_id={user.tenant_id}")
|
||||
# Since tenant relationships are managed by tenant service, we need to call tenant service
|
||||
# to get the user's primary tenant and validate subscription status
|
||||
|
||||
# Get user's primary tenant from tenant service
|
||||
primary_tenant = await self.tenant_client.get_user_primary_tenant(str(user.id))
|
||||
|
||||
if primary_tenant:
|
||||
tenant_id = primary_tenant.get('tenant_id')
|
||||
logger.info(f"User has primary tenant - subscription validation required, user_id={user.id}, tenant_id={tenant_id}")
|
||||
|
||||
# Call tenant service to validate subscription status
|
||||
subscription_status = await self.tenant_client.get_subscription_status(user.tenant_id)
|
||||
subscription_status = await self.tenant_client.get_subscription_status(tenant_id)
|
||||
|
||||
if subscription_status:
|
||||
is_active = subscription_status.get('is_active', False)
|
||||
status = subscription_status.get('status', 'unknown')
|
||||
|
||||
logger.info(f"Subscription status retrieved from tenant service, user_id={user.id}, tenant_id={user.tenant_id}, status={status}, is_active={is_active}")
|
||||
logger.info(f"Subscription status retrieved from tenant service, user_id={user.id}, tenant_id={tenant_id}, status={status}")
|
||||
|
||||
# Consider subscription valid if it's active, trialing, or in grace period
|
||||
valid_statuses = ['active', 'trialing', 'grace_period']
|
||||
return is_active and status in valid_statuses
|
||||
return status in valid_statuses
|
||||
else:
|
||||
logger.warning(f"No subscription status returned from tenant service, user_id={user.id}, tenant_id={user.tenant_id}")
|
||||
logger.warning(f"No subscription status returned from tenant service, user_id={user.id}, tenant_id={tenant_id}")
|
||||
return False
|
||||
else:
|
||||
# Users without tenant might be in registration flow
|
||||
logger.info(f"User without tenant - no subscription validation, user_id={user.id}")
|
||||
# Users without primary tenant might be in registration flow or using free tier
|
||||
logger.info(f"User without primary tenant - no subscription validation required, user_id={user.id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -496,24 +501,32 @@ class AuthService:
|
||||
try:
|
||||
logger.info(f"Getting subscription details, user_id={user.id}")
|
||||
|
||||
if user.tenant_id:
|
||||
# Get user's primary tenant from tenant service
|
||||
primary_tenant = await self.tenant_client.get_user_primary_tenant(str(user.id))
|
||||
|
||||
if primary_tenant:
|
||||
tenant_id = primary_tenant.get('tenant_id')
|
||||
|
||||
# Call tenant service to get subscription details
|
||||
subscription = await self.tenant_client.get_subscription_details(user.tenant_id)
|
||||
subscription = await self.tenant_client.get_subscription_details(tenant_id)
|
||||
|
||||
if subscription:
|
||||
logger.info(f"Subscription details retrieved from tenant service, user_id={user.id}, tenant_id={user.tenant_id}, plan={subscription.get('plan')}, status={subscription.get('status')}")
|
||||
logger.info(f"Subscription details retrieved from tenant service, user_id={user.id}, tenant_id={tenant_id}, plan={subscription.get('plan')}, status={subscription.get('status')}")
|
||||
# Add tenant_id to subscription details for JWT
|
||||
subscription['tenant_id'] = tenant_id
|
||||
return subscription
|
||||
else:
|
||||
logger.warning(f"No subscription details returned from tenant service, user_id={user.id}, tenant_id={user.tenant_id}")
|
||||
logger.warning(f"No subscription details returned from tenant service, user_id={user.id}, tenant_id={tenant_id}")
|
||||
return {
|
||||
"status": "no_subscription",
|
||||
"plan": None,
|
||||
"billing_cycle": None,
|
||||
"current_period_end": None,
|
||||
"trial_period_days": 0
|
||||
"trial_period_days": 0,
|
||||
"tenant_id": tenant_id
|
||||
}
|
||||
else:
|
||||
logger.info(f"User without tenant - no subscription details available, user_id={user.id}")
|
||||
logger.info(f"User without primary tenant - no subscription details available, user_id={user.id}")
|
||||
return {
|
||||
"status": "no_tenant",
|
||||
"plan": None,
|
||||
@@ -531,7 +544,35 @@ class AuthService:
|
||||
"current_period_end": None,
|
||||
"trial_period_days": 0
|
||||
}
|
||||
|
||||
|
||||
async def get_user_by_id(self, user_id: str) -> Optional[User]:
|
||||
"""
|
||||
Get user by ID
|
||||
|
||||
Args:
|
||||
user_id: User ID to retrieve
|
||||
|
||||
Returns:
|
||||
User object if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Getting user by ID, user_id={user_id}")
|
||||
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(User, session)
|
||||
user = await user_repo.get_by_id(user_id)
|
||||
|
||||
if user:
|
||||
logger.info(f"User retrieved successfully, user_id={user_id}, email={user.email}")
|
||||
else:
|
||||
logger.warning(f"User not found, user_id={user_id}")
|
||||
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get user by ID failed, error={str(e)}, user_id={user_id}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def _generate_auth_tokens(self, user: User) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate authentication tokens for user
|
||||
@@ -578,6 +619,9 @@ class AuthService:
|
||||
# Generate refresh token using SecurityManager
|
||||
refresh_token = SecurityManager.create_refresh_token(refresh_token_data)
|
||||
|
||||
# ✅ CRITICAL FIX: Store refresh token in Redis for later validation
|
||||
await SecurityManager.store_refresh_token(str(user.id), refresh_token)
|
||||
|
||||
logger.info(f"Auth tokens generated successfully, user_id={user.id}, access_token_length={len(access_token)}, refresh_token_length={len(refresh_token)}")
|
||||
|
||||
return {
|
||||
@@ -595,6 +639,323 @@ class AuthService:
|
||||
detail=f"Token generation failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
async def refresh_auth_tokens(self, refresh_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Refresh authentication tokens using a valid refresh token
|
||||
|
||||
Args:
|
||||
refresh_token: Valid refresh token
|
||||
|
||||
Returns:
|
||||
Dictionary with new access and refresh tokens
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 for invalid refresh tokens
|
||||
"""
|
||||
try:
|
||||
logger.info("Refreshing auth tokens using refresh token")
|
||||
|
||||
# Import JWT dependencies
|
||||
from jose import jwt, JWTError
|
||||
from app.core.config import settings
|
||||
from app.core.security import SecurityManager
|
||||
|
||||
# Decode refresh token to get user info
|
||||
payload = jwt.decode(
|
||||
refresh_token,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithms=[settings.JWT_ALGORITHM]
|
||||
)
|
||||
|
||||
# Verify this is a refresh token
|
||||
if payload.get("type") != "refresh":
|
||||
logger.warning("Invalid token type for refresh")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type"
|
||||
)
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
email = payload.get("email")
|
||||
|
||||
if not user_id or not email:
|
||||
logger.warning("Invalid refresh token payload")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# Check if refresh token is valid in Redis using SecurityManager
|
||||
is_valid = await SecurityManager.is_refresh_token_valid(user_id, refresh_token)
|
||||
if not is_valid:
|
||||
logger.warning(f"Invalid or expired refresh token for user {user_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token"
|
||||
)
|
||||
|
||||
logger.info(f"Refresh token validated for user {user_id}, email={email}")
|
||||
|
||||
# Get user from database
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(User, session)
|
||||
user = await user_repo.get_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
logger.warning(f"User not found for refresh token, user_id={user_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning(f"Inactive user attempted token refresh, user_id={user_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Account is inactive"
|
||||
)
|
||||
|
||||
# Generate new tokens using existing method
|
||||
tokens = await self._generate_auth_tokens(user)
|
||||
|
||||
logger.info(f"Token refresh successful, user_id={user_id}, email={email}")
|
||||
|
||||
return tokens
|
||||
|
||||
except JWTError as e:
|
||||
logger.warning(f"JWT decode error during refresh: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Token refresh failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
async def verify_access_token(self, access_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify the validity of an access token
|
||||
|
||||
Args:
|
||||
access_token: Access token to verify
|
||||
|
||||
Returns:
|
||||
Dictionary with token validation result and user info
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 for invalid access tokens
|
||||
"""
|
||||
try:
|
||||
logger.info("Verifying access token")
|
||||
|
||||
# Import JWT dependencies
|
||||
from jose import jwt, JWTError
|
||||
from app.core.config import settings
|
||||
|
||||
# Decode and verify token
|
||||
payload = jwt.decode(
|
||||
access_token,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithms=[settings.JWT_ALGORITHM]
|
||||
)
|
||||
|
||||
# Verify this is an access token
|
||||
if payload.get("type") != "access":
|
||||
logger.warning("Invalid token type for verification")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type"
|
||||
)
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
email = payload.get("email")
|
||||
|
||||
if not user_id or not email:
|
||||
logger.warning("Invalid access token payload")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token"
|
||||
)
|
||||
|
||||
logger.info(f"Token verification successful, user_id={user_id}, email={email}")
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"user_id": user_id,
|
||||
"email": email
|
||||
}
|
||||
|
||||
except JWTError as e:
|
||||
logger.warning(f"JWT decode error during verification: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token"
|
||||
)
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Token verification failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
async def revoke_refresh_token(self, refresh_token: str) -> bool:
|
||||
"""
|
||||
Revoke a refresh token
|
||||
|
||||
Args:
|
||||
refresh_token: Refresh token to revoke
|
||||
|
||||
Returns:
|
||||
True if revocation was successful or token didn't exist
|
||||
|
||||
Raises:
|
||||
Exception: For unexpected errors
|
||||
"""
|
||||
try:
|
||||
logger.info("Revoking refresh token")
|
||||
|
||||
# Try to decode token to get user_id
|
||||
from jose import jwt, JWTError
|
||||
from app.core.config import settings
|
||||
|
||||
user_id = "unknown"
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
refresh_token,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithms=[settings.JWT_ALGORITHM]
|
||||
)
|
||||
user_id = payload.get("user_id") or "unknown"
|
||||
except JWTError as e:
|
||||
logger.warning(f"Could not decode refresh token during revocation: {str(e)}")
|
||||
|
||||
# Revoke the token using SecurityManager
|
||||
await SecurityManager.revoke_refresh_token(user_id, refresh_token)
|
||||
logger.info(f"Refresh token revoked successfully, user_id={user_id}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error revoking refresh token: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Token revocation failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
async def change_user_password(self, user_id: str, current_password: str, new_password: str) -> bool:
|
||||
"""
|
||||
Change user password
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
current_password: Current password for verification
|
||||
new_password: New password to set
|
||||
|
||||
Returns:
|
||||
True if password was changed successfully
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 for invalid current password, 404 for user not found
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Changing password for user {user_id}")
|
||||
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
# Get current user
|
||||
user = await user_repo.get_by_id(user_id)
|
||||
if not user:
|
||||
logger.warning(f"User not found for password change, user_id={user_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Verify current password
|
||||
from app.core.security import verify_password
|
||||
is_valid = verify_password(current_password, user.hashed_password)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"Invalid current password for user {user_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Current password is incorrect"
|
||||
)
|
||||
|
||||
# Update password
|
||||
await user_repo.update_password(user_id, new_password)
|
||||
|
||||
logger.info(f"Password change successful, user_id={user_id}, email={user.email}")
|
||||
|
||||
return True
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Password change failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Password change failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
async def verify_user_email(self, email: str, verification_token: str) -> bool:
|
||||
"""
|
||||
Verify user email address
|
||||
|
||||
Args:
|
||||
email: User email
|
||||
verification_token: Email verification token
|
||||
|
||||
Returns:
|
||||
True if email was verified successfully
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 for user not found
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Verifying email for {email}")
|
||||
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
# Find user by email
|
||||
user = await user_repo.get_by_email(email)
|
||||
if not user:
|
||||
logger.warning(f"User not found for email verification, email={email}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# TODO: Implement actual email verification token logic
|
||||
# For now, just mark as verified
|
||||
await user_repo.update_user(user.id, {"is_verified": True})
|
||||
|
||||
logger.info(f"Email verification successful, user_id={user.id}, email={email}")
|
||||
|
||||
return True
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Email verification failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Email verification failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
async def request_password_reset(self, email: str) -> bool:
|
||||
"""
|
||||
Request a password reset for a user
|
||||
|
||||
@@ -10,7 +10,8 @@ from fastapi import HTTPException, status
|
||||
import structlog
|
||||
|
||||
from app.repositories import UserRepository, TokenRepository
|
||||
from app.schemas.auth import UserResponse, UserUpdate
|
||||
from app.schemas.auth import UserResponse
|
||||
from app.schemas.users import UserUpdate
|
||||
from app.models.users import User
|
||||
from app.models.tokens import RefreshToken
|
||||
from app.core.security import SecurityManager
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
"""Fetches subscription data for JWT enrichment at login time"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
import httpx
|
||||
from typing import Dict, Any, Optional, List
|
||||
import logging
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from shared.clients.tenant_client import TenantServiceClient
|
||||
from shared.config.base import BaseServiceSettings
|
||||
|
||||
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)
|
||||
def __init__(self, config: BaseServiceSettings):
|
||||
"""
|
||||
Initialize SubscriptionFetcher with service configuration
|
||||
|
||||
Args:
|
||||
config: BaseServiceSettings containing service configuration
|
||||
"""
|
||||
self.tenant_client = TenantServiceClient(config)
|
||||
logger.info("SubscriptionFetcher initialized with TenantServiceClient")
|
||||
|
||||
async def get_user_subscription_context(
|
||||
self,
|
||||
user_id: str,
|
||||
service_token: str
|
||||
user_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch user's tenant memberships and subscription data.
|
||||
Fetch user's tenant memberships and subscription data using shared tenant client.
|
||||
Called ONCE at login, not per-request.
|
||||
|
||||
This method uses the shared TenantServiceClient instead of direct HTTP calls,
|
||||
providing better error handling, circuit breaking, and consistency.
|
||||
|
||||
Returns:
|
||||
{
|
||||
@@ -39,103 +49,75 @@ class SubscriptionFetcher:
|
||||
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"
|
||||
# Get user's tenant memberships using shared tenant client
|
||||
memberships = await self.tenant_client.get_user_memberships(user_id)
|
||||
|
||||
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": []
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
# 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
|
||||
|
||||
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": []
|
||||
}
|
||||
primary_tenant_id = primary_membership["tenant_id"]
|
||||
primary_role = primary_membership["role"]
|
||||
|
||||
# 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
|
||||
})
|
||||
# Get subscription for primary tenant using shared tenant client
|
||||
subscription_data = await self.tenant_client.get_subscription_details(primary_tenant_id)
|
||||
|
||||
if not subscription_data:
|
||||
logger.warning(f"No subscription data found for primary tenant {primary_tenant_id}")
|
||||
# Return with basic info but no subscription
|
||||
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
|
||||
"subscription": None,
|
||||
"tenant_access": memberships
|
||||
}
|
||||
|
||||
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)}"
|
||||
)
|
||||
# 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 using shared tenant client
|
||||
tenant_sub = await self.tenant_client.get_subscription_details(tenant_id)
|
||||
|
||||
tier = "starter" # default
|
||||
if tenant_sub:
|
||||
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 Exception as e:
|
||||
logger.error(f"Error fetching subscription data: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
|
||||
Reference in New Issue
Block a user