Add subcription feature 6

This commit is contained in:
Urtzi Alfaro
2026-01-16 15:19:34 +01:00
parent 6b43116efd
commit 4bafceed0d
35 changed files with 3826 additions and 1789 deletions

View File

@@ -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")}
)

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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',

View File

@@ -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"""

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -74,7 +74,7 @@ async def create_ingredient(
async with httpx.AsyncClient(timeout=5.0) as client:
try:
limit_check_response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/subscriptions/{tenant_id}/can-add-product",
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/subscription/limits/products",
headers={
"x-user-id": str(current_user.get('user_id')),
"x-tenant-id": str(tenant_id)
@@ -185,7 +185,7 @@ async def bulk_create_ingredients(
try:
# Check if we can add this many products
limit_check_response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/subscriptions/{tenant_id}/can-add-products/{total_requested}",
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/subscription/limits/products",
headers={
"x-user-id": str(current_user.get('user_id')),
"x-tenant-id": str(tenant_id)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -606,3 +606,75 @@ class TenantRepository(TenantBaseRepository):
customer_id=customer_id,
error=str(e))
raise DatabaseError(f"Failed to get tenant by customer_id: {str(e)}")
async def get_user_primary_tenant(self, user_id: str) -> Optional[Tenant]:
"""
Get the primary tenant for a user (the tenant they own)
Args:
user_id: User ID to find primary tenant for
Returns:
Tenant object if found, None otherwise
"""
try:
logger.debug("Getting primary tenant for user", user_id=user_id)
# Query for tenant where user is the owner
query = select(Tenant).where(Tenant.owner_id == user_id)
result = await self.session.execute(query)
tenant = result.scalar_one_or_none()
if tenant:
logger.debug("Found primary tenant for user",
user_id=user_id,
tenant_id=str(tenant.id))
return tenant
else:
logger.debug("No primary tenant found for user", user_id=user_id)
return None
except Exception as e:
logger.error("Error getting primary tenant for user",
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to get primary tenant for user: {str(e)}")
async def get_any_user_tenant(self, user_id: str) -> Optional[Tenant]:
"""
Get any tenant that the user has access to (via tenant_members)
Args:
user_id: User ID to find accessible tenants for
Returns:
Tenant object if found, None otherwise
"""
try:
logger.debug("Getting any accessible tenant for user", user_id=user_id)
# Query for tenant members where user has access
from app.models.tenants import TenantMember
query = select(Tenant).join(
TenantMember, Tenant.id == TenantMember.tenant_id
).where(TenantMember.user_id == user_id)
result = await self.session.execute(query)
tenant = result.scalar_one_or_none()
if tenant:
logger.debug("Found accessible tenant for user",
user_id=user_id,
tenant_id=str(tenant.id))
return tenant
else:
logger.debug("No accessible tenants found for user", user_id=user_id)
return None
except Exception as e:
logger.error("Error getting accessible tenant for user",
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to get accessible tenant for user: {str(e)}")

View File

@@ -1545,6 +1545,52 @@ class SubscriptionOrchestrationService:
tenant_id=tenant_id)
return None
async def get_invoices(self, tenant_id: str) -> Dict[str, Any]:
"""
Get invoice history for a tenant's subscription
This is an orchestration method that coordinates between:
1. SubscriptionService (to get subscription data)
2. PaymentService (to get invoices from provider)
Args:
tenant_id: Tenant ID
Returns:
Dictionary with invoices data
"""
try:
# Get subscription from database
subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id)
if not subscription:
logger.warning("get_invoices_no_subscription",
tenant_id=tenant_id)
return {"invoices": []}
# Check if subscription has a customer ID
if not subscription.customer_id:
logger.warning("get_invoices_no_customer_id",
tenant_id=tenant_id)
return {"invoices": []}
# Get invoices from payment provider
invoices_result = await self.payment_service.stripe_client.get_invoices(subscription.customer_id)
logger.info("invoices_retrieved",
tenant_id=tenant_id,
customer_id=subscription.customer_id,
invoice_count=len(invoices_result.get("invoices", [])))
return invoices_result
except Exception as e:
logger.error("get_invoices_failed",
error=str(e),
tenant_id=tenant_id,
exc_info=True)
return {"invoices": []}
async def update_payment_method(
self,
tenant_id: str,

View File

@@ -252,7 +252,7 @@ class SubscriptionCreationFlowTester:
async def _verify_subscription_linked_to_tenant(self, subscription_id: str, tenant_id: str):
"""Verify that the subscription is properly linked to the tenant"""
url = f"{self.base_url}/api/v1/subscriptions/{tenant_id}/status"
url = f"{self.base_url}/api/v1/tenants/{tenant_id}/subscription/status"
# Get access token for the user
access_token = await self._get_user_access_token()
@@ -280,7 +280,7 @@ class SubscriptionCreationFlowTester:
async def _verify_tenant_subscription_access(self, tenant_id: str):
"""Verify that the tenant can access its subscription"""
url = f"{self.base_url}/api/v1/subscriptions/{tenant_id}/active"
url = f"{self.base_url}/api/v1/tenants/{tenant_id}/subscription/details"
# Get access token for the user
access_token = await self._get_user_access_token()