diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 92168697..c0acff3d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -356,37 +356,35 @@ jobs: run: | BUILDTAG=${{ needs.Generate-Tag.outputs.tag_name }} - # Determine environment and database - # Note: Using Aurora Serverless v2 endpoints (cypher.*.mor.org) - # Aurora has replaced the old RDS instances (db.*.mor.org) + # Determine environment from branch if [ "${{ github.ref_name }}" == "test" ] || [[ "${{ github.ref_name }}" == cicd/* ]]; then ENV="dev" - DB_HOST="cypher.dev.mor.org" elif [ "${{ github.ref_name }}" == "stg" ]; then ENV="stg" - DB_HOST="cypher.stg.mor.org" elif [ "${{ github.ref_name }}" == "main" ]; then ENV="prd" - DB_HOST="cypher.mor.org" else echo "❌ Unsupported branch for deployment: ${{ github.ref_name }}" exit 1 fi - echo "🗄️ Running database migrations for environment: $ENV" - echo "📍 Database host: $DB_HOST" - - # Get database credentials from AWS Secrets Manager + # Get database connection details from the dedicated DB-creds secret + # (contains only POSTGRES_USER/PASSWORD/DB/HOST/PORT — no app secrets) SECRET_VALUE=$(aws secretsmanager get-secret-value \ - --secret-id "${ENV}-morpheus-api" \ + --secret-id "${ENV}-morpheus-api-rds-proxy-credentials" \ --query SecretString --output text) DB_USER=$(echo "$SECRET_VALUE" | jq -r '.POSTGRES_USER') DB_PASSWORD=$(echo "$SECRET_VALUE" | jq -r '.POSTGRES_PASSWORD') DB_NAME=$(echo "$SECRET_VALUE" | jq -r '.POSTGRES_DB') + DB_HOST=$(echo "$SECRET_VALUE" | jq -r '.POSTGRES_HOST') + DB_PORT=$(echo "$SECRET_VALUE" | jq -r '.POSTGRES_PORT') + + echo "🗄️ Running database migrations for environment: $ENV" + echo "📍 Database host: $DB_HOST" # Set database URL for migrations - export DATABASE_URL="postgresql+asyncpg://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}" + export DATABASE_URL="postgresql+asyncpg://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" export ENVIRONMENT="$ENV" # Create backup point (get current revision before migration) @@ -793,36 +791,33 @@ jobs: exit 0 fi - # Determine environment and database - # Note: Using Aurora Serverless v2 endpoints (cypher.*.mor.org) - # Aurora has replaced the old RDS instances (db.*.mor.org) + # Determine environment from branch if [ "${{ github.ref_name }}" == "test" ] || [[ "${{ github.ref_name }}" == cicd/* ]]; then ENV="dev" - DB_HOST="cypher.dev.mor.org" elif [ "${{ github.ref_name }}" == "stg" ]; then ENV="stg" - DB_HOST="cypher.stg.mor.org" elif [ "${{ github.ref_name }}" == "main" ]; then ENV="prd" - DB_HOST="cypher.mor.org" else echo "❌ Unknown environment for rollback" exit 1 fi - echo "📍 Rolling back database in environment: $ENV" - echo "🔄 Target rollback revision: ${{ env.PRE_MIGRATION_REVISION }}" - - # Get database credentials from AWS Secrets Manager + # Get database connection details from the dedicated DB-creds secret SECRET_VALUE=$(aws secretsmanager get-secret-value \ - --secret-id "${ENV}-morpheus-api" \ + --secret-id "${ENV}-morpheus-api-rds-proxy-credentials" \ --query SecretString --output text) DB_USER=$(echo "$SECRET_VALUE" | jq -r '.POSTGRES_USER') DB_PASSWORD=$(echo "$SECRET_VALUE" | jq -r '.POSTGRES_PASSWORD') DB_NAME=$(echo "$SECRET_VALUE" | jq -r '.POSTGRES_DB') + DB_HOST=$(echo "$SECRET_VALUE" | jq -r '.POSTGRES_HOST') + DB_PORT=$(echo "$SECRET_VALUE" | jq -r '.POSTGRES_PORT') + + echo "📍 Rolling back database in environment: $ENV" + echo "🔄 Target rollback revision: ${{ env.PRE_MIGRATION_REVISION }}" - export DATABASE_URL="postgresql+asyncpg://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}" + export DATABASE_URL="postgresql+asyncpg://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" # Check current revision before rollback CURRENT_REV=$(poetry run alembic current --verbose 2>/dev/null | grep "Current revision" | awk '{print $NF}' || echo "none") diff --git a/alembic/versions/2026_03_05_0001_drop_email_and_name_columns.py b/alembic/versions/2026_03_05_0001_drop_email_and_name_columns.py new file mode 100644 index 00000000..21a955bf --- /dev/null +++ b/alembic/versions/2026_03_05_0001_drop_email_and_name_columns.py @@ -0,0 +1,32 @@ +"""Drop email and name columns from users table + +PII (email, name) is managed exclusively in Cognito. The API resolves email +on-demand via the user's access token when needed (e.g. GET /me). The database +only stores cognito_user_id as the identity key. + +Revision ID: drop_email_name_2026 +Revises: e1f2a3b4c5d6 +Create Date: 2026-03-05 00:01:00.000000 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'drop_email_name_2026' +down_revision: Union[str, None] = 'e1f2a3b4c5d6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_index('ix_users_email_nonunique', table_name='users') + op.drop_column('users', 'email') + op.drop_column('users', 'name') + + +def downgrade() -> None: + op.add_column('users', sa.Column('name', sa.String(), nullable=True)) + op.add_column('users', sa.Column('email', sa.String(), nullable=True)) + op.create_index('ix_users_email_nonunique', 'users', ['email'], unique=False) diff --git a/src/api/v1/auth/index.py b/src/api/v1/auth/index.py index 430807a8..1ab77c9c 100644 --- a/src/api/v1/auth/index.py +++ b/src/api/v1/auth/index.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, HTTPException, status, Depends, Body, Request, Response, Query +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from ....crud import user as user_crud @@ -20,43 +21,40 @@ router = APIRouter(tags=["Auth"]) -# Note: Authentication is now handled by Cognito -# Users authenticate via Cognito OAuth2 flow and receive JWT tokens -# The frontend should redirect to Cognito for login/registration - -# OAuth2 callback is handled by the /docs/oauth2-redirect endpoint +_bearer = HTTPBearer(auto_error=False) @router.get("/me", response_model=dict) async def get_current_user_info( current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db_session) + token: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), ): """ Get current user information. - - Requires JWT Bearer authentication with Cognito token. - User data is automatically kept up-to-date during authentication. + + Email and name are fetched live from Cognito (not stored in DB). + Uses the caller's own access token — same pattern as the frontend. """ - user = current_user - data_source = "database_auto_updated" - - # Return user data focusing on email and cognito_id - response_data = { - "id": user.id, - "cognito_user_id": user.cognito_user_id, - "email": user.email, - "name": user.name, - "is_active": user.is_active, - "age_verified": user.age_verified, - "age_verified_at": user.age_verified_at, - "created_at": user.created_at, - "updated_at": user.updated_at, - "data_source": data_source + email = None + name = None + + if token: + cognito_info = await cognito_service.get_user_by_token(token.credentials) + if cognito_info: + email = cognito_info.get('email') + name = cognito_info.get('name') + + return { + "id": current_user.id, + "cognito_user_id": current_user.cognito_user_id, + "email": email, + "name": name, + "is_active": current_user.is_active, + "age_verified": current_user.age_verified, + "age_verified_at": current_user.age_verified_at, + "created_at": current_user.created_at, + "updated_at": current_user.updated_at, + "data_source": "cognito_live", } - - # Email should now be automatically updated during authentication - - return response_data @router.post("/verify-age", response_model=dict) async def verify_age( @@ -383,7 +381,7 @@ async def get_default_api_key_decrypted( "name": api_key_obj.name, "created_at": api_key_obj.created_at }, - "suggestion": "Try refreshing your user data with GET /api/v1/auth/me?refresh_from_cognito=true, or create a new API key" + "suggestion": "Try creating a new API key using POST /api/v1/auth/keys" } # Success case diff --git a/src/core/config.py b/src/core/config.py index 5d19a924..6da3588e 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -164,11 +164,8 @@ def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str PROXY_ROUTER_CHAT_TIMEOUT: float = Field(default=float(os.getenv("PROXY_ROUTER_CHAT_TIMEOUT", "300.0"))) PROXY_ROUTER_STREAM_TIMEOUT: float = Field(default=float(os.getenv("PROXY_ROUTER_STREAM_TIMEOUT", "300.0"))) - # AWS settings + # AWS settings (credentials come from ECS task role; no explicit keys needed) AWS_REGION: str = os.getenv("AWS_REGION", "us-east-2") - AWS_ACCESS_KEY_ID: str | None = Field(default=os.getenv("AWS_ACCESS_KEY_ID")) - AWS_SECRET_ACCESS_KEY: str | None = Field(default=os.getenv("AWS_SECRET_ACCESS_KEY")) - AWS_SESSION_TOKEN: str | None = Field(default=os.getenv("AWS_SESSION_TOKEN")) # AWS Cognito Settings COGNITO_USER_POOL_ID: str = Field(default=os.getenv("COGNITO_USER_POOL_ID", "us-east-2_tqCTHoSST")) diff --git a/src/core/local_testing.py b/src/core/local_testing.py index 759f78de..82f6a372 100644 --- a/src/core/local_testing.py +++ b/src/core/local_testing.py @@ -33,16 +33,9 @@ async def get_or_create_test_user(db: AsyncSession) -> User: test_user = await user_crud.get_user_by_cognito_id(db, "local-test-user") if not test_user: - # Create test user - user_data = { - 'cognito_user_id': 'local-test-user', - 'email': 'test@local.dev', - 'name': 'Local Test User' - } - test_user = await user_crud.create_user_from_cognito(db, user_data) + test_user = await user_crud.create_user_from_cognito(db, 'local-test-user') logger.info("Created test user for local development", test_user_id=test_user.id, - test_email=user_data['email'], event_type="test_user_created") return test_user @@ -52,7 +45,7 @@ def log_local_testing_status(): if is_local_testing_mode(): logger.warning("LOCAL TESTING MODE ACTIVE", bypass_cognito=True, - test_user_email="test@local.dev", + test_cognito_id="local-test-user", production_safe=False, event_type="local_testing_active") logger.warning("Cognito authentication BYPASSED - NOT FOR PRODUCTION USE", diff --git a/src/crud/api_key.py b/src/crud/api_key.py index dfcc3c53..7badc542 100644 --- a/src/crud/api_key.py +++ b/src/crud/api_key.py @@ -357,7 +357,6 @@ async def get_decrypted_api_key(db: AsyncSession, api_key_id: int, user_id: int) api_key_id=api_key_id, user_id=user_id, cognito_user_id=api_key.user.cognito_user_id[:8] + "...", - user_email=api_key.user.email, encrypted_key_length=len(api_key.encrypted_key), event_type="api_key_found") diff --git a/src/crud/user.py b/src/crud/user.py index 9e947a36..3c125704 100644 --- a/src/crud/user.py +++ b/src/crud/user.py @@ -5,109 +5,45 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.db.models import User -from src.schemas.user import UserCreate, UserUpdate from src.services.cache_service import cache_service from src.core.logging_config import get_auth_logger logger = get_auth_logger() async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]: - """ - Get a user by ID. - - Args: - db: Database session - user_id: User ID - - Returns: - User object if found, None otherwise - """ result = await db.execute(select(User).where(User.id == user_id)) return result.scalars().first() async def get_user_by_cognito_id(db: AsyncSession, cognito_user_id: str) -> Optional[User]: - """ - Get a user by Cognito user ID. - - Args: - db: Database session - cognito_user_id: Cognito user ID (sub claim) - - Returns: - User object if found, None otherwise - """ result = await db.execute(select(User).where(User.cognito_user_id == cognito_user_id)) return result.scalars().first() -async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]: - """ - Get a user by email. - - Args: - db: Database session - email: User email - - Returns: - User object if found, None otherwise - """ - result = await db.execute(select(User).where(User.email == email)) - return result.scalars().first() - -async def create_user_from_cognito(db: AsyncSession, user_data: dict) -> User: - """ - Create a new user from Cognito authentication data. - - Args: - db: Database session - user_data: User data from Cognito token - - Returns: - Created user object - """ - # Create user object +async def create_user_from_cognito(db: AsyncSession, cognito_user_id: str) -> User: + """Create a new user row keyed by cognito_user_id (no PII stored).""" db_user = User( - cognito_user_id=user_data['cognito_user_id'], - email=user_data['email'], - name=user_data.get('name'), - is_active=user_data.get('is_active', True) + cognito_user_id=cognito_user_id, + is_active=True, ) - - # Add to database db.add(db_user) await db.commit() await db.refresh(db_user) logger.info("User created from Cognito authentication", user_id=db_user.id, - cognito_user_id=user_data['cognito_user_id'], - email=user_data['email'], + cognito_user_id=cognito_user_id, event_type="user_created_from_cognito") return db_user async def update_user( - db: AsyncSession, *, db_user: User, user_in: Union[UserUpdate, dict] + db: AsyncSession, *, db_user: User, user_in: dict ) -> User: - """ - Update a user. - - Args: - db: Database session - db_user: User object to update - user_in: User update data - - Returns: - Updated user object - """ - # Convert to dict if not already update_data = user_in if isinstance(user_in, dict) else user_in.model_dump(exclude_unset=True) - # Update user fields (exclude cognito_user_id and id which shouldn't be updated) for field, value in update_data.items(): if hasattr(db_user, field) and field not in ["id", "cognito_user_id"]: setattr(db_user, field, value) - # Commit changes await db.commit() await db.refresh(db_user) @@ -116,7 +52,6 @@ async def update_user( updated_fields=list(update_data.keys()), event_type="user_updated") - # Invalidate user cache await cache_service.delete("user", db_user.cognito_user_id) return db_user @@ -160,7 +95,6 @@ async def delete_user(db: AsyncSession, user_id: int) -> Optional[User]: logger.info("Deleting user", user_id=user_id, cognito_user_id=user.cognito_user_id, - email=user.email, event_type="user_deletion") # Invalidate user cache @@ -176,75 +110,6 @@ async def delete_user(db: AsyncSession, user_id: int) -> Optional[User]: return user -async def update_user_from_cognito( - db: AsyncSession, *, db_user: User, cognito_service -) -> Optional[User]: - """ - Update user data by fetching fresh information from Cognito. - - Args: - db: Database session - db_user: User object to update - cognito_service: Cognito service instance - - Returns: - Updated user object or None if Cognito fetch fails - """ - try: - logger.debug("Fetching user info from Cognito", - user_id=db_user.id, - cognito_user_id=db_user.cognito_user_id, - event_type="cognito_user_info_fetch_start") - - # Fetch user info from Cognito - cognito_info = await cognito_service.get_user_info(db_user.cognito_user_id) - - if not cognito_info: - logger.warning("No user info received from Cognito", - user_id=db_user.id, - cognito_user_id=db_user.cognito_user_id, - event_type="cognito_user_info_not_found") - return None - - # Extract attributes from Cognito response - attributes = cognito_info.get('attributes', {}) - email = attributes.get('email') - - # Prepare update data - update_data = {} - - # Update email if we have a real email from Cognito and it's different - if email and email != db_user.email: - update_data['email'] = email - # Only update name if it's currently empty - if not db_user.name: - update_data['name'] = email # Use email as name since no name fields are collected - - # Apply updates if we have any - if update_data: - logger.info("Updating user with Cognito data", - user_id=db_user.id, - update_fields=list(update_data.keys()), - new_email=email or 'not_provided', - event_type="cognito_user_data_update") - return await update_user(db, db_user=db_user, user_in=update_data) - - logger.debug("No updates needed from Cognito", - user_id=db_user.id, - has_email=bool(email), - event_type="cognito_no_updates_needed") - return db_user - - except Exception as e: - # Log error but don't fail - return the original user - logger.error("Failed to update user from Cognito", - user_id=db_user.id, - cognito_user_id=db_user.cognito_user_id, - error=str(e), - event_type="cognito_user_update_error") - return db_user - - async def set_age_verification(db: AsyncSession, user_id: int, verified: bool) -> Optional[User]: """ Record the user's 18+ age verification consent. diff --git a/src/db/models/user.py b/src/db/models/user.py index cf88eea0..c2509874 100644 --- a/src/db/models/user.py +++ b/src/db/models/user.py @@ -1,5 +1,8 @@ """ User model for authentication and account management. + +PII (email, name) lives exclusively in Cognito. The database only stores +cognito_user_id as the identity key and application-level fields. """ from sqlalchemy import Column, Integer, String, Boolean, DateTime from sqlalchemy.orm import relationship @@ -14,8 +17,6 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) cognito_user_id = Column(String, unique=True, index=True, nullable=False) # Cognito 'sub' claim - email = Column(String, unique=True, index=True, nullable=False) # From Cognito token - name = Column(String, nullable=True) # From Cognito token (given_name/family_name) is_active = Column(Boolean, default=True) age_verified = Column(Boolean, default=False, nullable=False) age_verified_at = Column(DateTime, nullable=True) diff --git a/src/dependencies.py b/src/dependencies.py index 9ac6d9f4..f80db152 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -9,7 +9,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload from sqlalchemy import select -import boto3 from jose import jwt, jwk import httpx @@ -19,34 +18,27 @@ from src.crud import api_key as api_key_crud from src.db.database import get_db, get_db_session from src.db.models import User, APIKey -from src.schemas.token import TokenPayload -from src.services.cognito_service import cognito_service from src.services.cache_service import cache_service from src.core.logging_config import get_auth_logger auth_logger = get_auth_logger() -# Define bearer token scheme for JWT authentication oauth2_scheme = HTTPBearer( auto_error=True, description="JWT Bearer token authentication" ) -# Define optional bearer token scheme for local testing oauth2_scheme_optional = HTTPBearer( auto_error=False, description="JWT Bearer token authentication (optional for local testing)" ) -# Define API key scheme for API key authentication api_key_header = APIKeyHeader( name="Authorization", auto_error=False, description="Provide the API key as 'Bearer sk-xxxxxx'" ) -cognito_client = boto3.client('cognito-idp', region_name=settings.AWS_REGION) - async def get_current_user( db: AsyncSession = Depends(get_db_session), token: Optional[HTTPAuthorizationCredentials] = Depends(oauth2_scheme_optional) @@ -153,9 +145,7 @@ async def get_current_user( email=payload.get('email'), event_type="jwt_validation_success") - # Extract user information from token cognito_user_id = payload.get('sub') - token_email = payload.get('email') if not cognito_user_id: auth_logger.error("Missing cognito_user_id (sub) in token payload", @@ -182,8 +172,6 @@ async def get_current_user( if user: user_cache_data = { 'id': user.id, - 'email': user.email, - 'name': user.name, 'is_active': user.is_active, 'age_verified': user.age_verified, 'age_verified_at': user.age_verified_at.isoformat() if user.age_verified_at else None, @@ -194,24 +182,13 @@ async def get_current_user( await cache_service.set("user", cognito_user_id, user_cache_data, ttl_seconds=600) if not user: - # Create new user with email and cognito_user_id - user_data = { - 'cognito_user_id': cognito_user_id, - 'email': token_email, # May be None for some auth methods (social, magic link, phone) - 'name': token_email, # Use email as name if available, otherwise None - 'is_active': True - } - user = await user_crud.create_user_from_cognito(db, user_data) + user = await user_crud.create_user_from_cognito(db, cognito_user_id) auth_logger.info("Created new user from Cognito token", - user_email=user_data['email'] or 'not_provided', cognito_user_id=cognito_user_id, event_type="user_creation") - # Cache newly created user user_cache_data = { 'id': user.id, - 'email': user.email, - 'name': user.name, 'is_active': user.is_active, 'age_verified': user.age_verified, 'age_verified_at': user.age_verified_at.isoformat() if user.age_verified_at else None, @@ -220,84 +197,6 @@ async def get_current_user( 'updated_at': user.updated_at.isoformat() if user.updated_at else None, } await cache_service.set("user", cognito_user_id, user_cache_data, ttl_seconds=600) - - # If email is missing, try to refresh from Cognito - if not token_email: - auth_logger.info("Email missing from JWT, refreshing from Cognito", - user_id=user.id, - cognito_user_id=cognito_user_id, - event_type="auto_refresh_new_user") - try: - updated_user = await user_crud.update_user_from_cognito(db, db_user=user, cognito_service=cognito_service) - if updated_user and updated_user.email: - user = updated_user - auth_logger.info("Successfully refreshed new user from Cognito", - user_id=user.id, - user_email=user.email, - event_type="auto_refresh_success") - else: - auth_logger.info("User created without email (auth method doesn't provide it)", - user_id=user.id, - event_type="user_no_email") - except Exception as e: - auth_logger.warning("Failed to refresh new user from Cognito", - user_id=user.id, - error=str(e), - event_type="auto_refresh_failed") - - else: - # Always check if user data needs updating - needs_update = False - update_data = {} - - # Check if JWT has email and it's different from stored email - if token_email and token_email != user.email: - update_data['email'] = token_email - update_data['name'] = token_email # Also update name to match email - needs_update = True - auth_logger.info("Updating user email from JWT token", - old_email=user.email or 'not_set', - new_email=token_email, - user_id=user.id, - event_type="user_email_update_from_jwt") - - # If email is missing and JWT doesn't have email, try refreshing from Cognito - elif not user.email and not token_email: - auth_logger.info("Email needs refresh from Cognito", - user_id=user.id, - cognito_user_id=cognito_user_id, - current_email=user.email or 'not_set', - has_jwt_email=bool(token_email), - event_type="auto_refresh_existing_user") - try: - refreshed_user = await user_crud.update_user_from_cognito(db, db_user=user, cognito_service=cognito_service) - if refreshed_user and refreshed_user.email != user.email: - user = refreshed_user - auth_logger.info("Successfully refreshed existing user from Cognito", - user_id=user.id, - old_email=user.email or 'not_set', - new_email=refreshed_user.email or 'still_not_available', - event_type="auto_refresh_success") - else: - auth_logger.debug("Cognito refresh returned same/no email", - user_id=user.id, - email=user.email or 'not_available', - event_type="auto_refresh_no_change") - except Exception as e: - auth_logger.warning("Failed to refresh existing user from Cognito", - user_id=user.id, - error=str(e), - event_type="auto_refresh_failed") - - # If we have JWT-based updates, apply them - if needs_update: - user = await user_crud.update_user(db, db_user=user, user_in=update_data) - auth_logger.info("Updated user with email from JWT token", - user_id=user.id, - event_type="user_update_complete") - - # Invalidate user cache on update - await cache_service.delete("user", cognito_user_id) return user @@ -382,8 +281,6 @@ async def get_api_key_auth( test_user = await get_or_create_test_user(db) user_dict = { 'id': test_user.id, - 'email': test_user.email, - 'name': test_user.name, 'is_active': test_user.is_active, 'cognito_user_id': test_user.cognito_user_id, 'created_at': test_user.created_at, @@ -476,8 +373,6 @@ async def _build_auth_from_cache( ud = cached_data["user"] user = User( id=ud["id"], - email=ud.get("email"), - name=ud.get("name"), is_active=ud.get("is_active", True), cognito_user_id=ud.get("cognito_user_id"), created_at=datetime.fromisoformat(ud["created_at"]) if ud.get("created_at") else None, @@ -560,8 +455,6 @@ async def _build_auth_from_db( # ── Build return objects ──────────────────────────────────────── user_dict = { 'id': db_user.id, - 'email': db_user.email, - 'name': db_user.name, 'is_active': db_user.is_active, 'cognito_user_id': db_user.cognito_user_id, 'created_at': db_user.created_at, diff --git a/src/schemas/user.py b/src/schemas/user.py index 22ba3965..55fd6df8 100644 --- a/src/schemas/user.py +++ b/src/schemas/user.py @@ -1,28 +1,20 @@ from typing import Optional -from pydantic import BaseModel, EmailStr, Field, ConfigDict +from pydantic import BaseModel, Field, ConfigDict from datetime import datetime -# Shared properties +# Shared properties (DB-backed fields only; email lives in Cognito) class UserBase(BaseModel): - email: Optional[EmailStr] = None # Optional: may not be provided by some auth methods (social, magic link, phone) - name: Optional[str] = None is_active: Optional[bool] = True -# Properties to receive on user creation -class UserCreate(UserBase): - password: str = Field(..., min_length=8) - -# Properties to receive on user update -class UserUpdate(UserBase): - password: Optional[str] = Field(None, min_length=8) - -# Properties to return to client +# Properties to return to client (email resolved from Cognito at request time) class UserResponse(UserBase): id: int + cognito_user_id: str + email: Optional[str] = None + name: Optional[str] = None age_verified: bool = False age_verified_at: Optional[datetime] = None - # Configure Pydantic to work with SQLAlchemy model_config = ConfigDict(from_attributes=True) # Age verification consent request @@ -38,21 +30,6 @@ class AgeVerificationRequest(BaseModel): } ) -# Properties for authentication -class UserLogin(BaseModel): - """Schema for user login credentials""" - email: EmailStr = Field(..., description="Email address for login") - password: str = Field(..., description="User password", min_length=8) - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "email": "user@example.com", - "password": "securepassword" - } - } - ) - # Properties for user deletion response class UserDeletionResponse(BaseModel): """Schema for user deletion response""" diff --git a/src/services/cognito_service.py b/src/services/cognito_service.py index 5dcd94f3..e0013a29 100644 --- a/src/services/cognito_service.py +++ b/src/services/cognito_service.py @@ -2,10 +2,12 @@ Cognito User Management Service Handles interactions with AWS Cognito User Pools for user lifecycle management. +Uses the ECS task role for admin operations (AdminDeleteUser) and the user's +own access token for user-level operations (GetUser). """ import boto3 -from typing import Optional, Dict, Any +from typing import Dict, Any, Optional from botocore.exceptions import ClientError, NoCredentialsError from src.core.config import settings @@ -14,20 +16,15 @@ logger = get_auth_logger() class CognitoUserService: - """Service for managing Cognito users""" + """Service for managing Cognito users.""" def __init__(self): - """Initialize Cognito client""" try: - # Create Cognito Identity Provider client self.cognito_client = boto3.client( 'cognito-idp', region_name=settings.COGNITO_REGION, - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - aws_session_token=settings.AWS_SESSION_TOKEN ) - logger.info("Cognito client initialized successfully", + logger.info("Cognito client initialized (using task role credentials)", cognito_region=settings.COGNITO_REGION, user_pool_id=settings.COGNITO_USER_POOL_ID, event_type="cognito_client_init_success") @@ -42,32 +39,51 @@ def __init__(self): cognito_region=settings.COGNITO_REGION, event_type="cognito_client_init_error") raise - + + async def get_user_by_token(self, access_token: str) -> Optional[Dict[str, Any]]: + """ + Fetch user attributes from Cognito using the user's own access token. + This mirrors what the frontend does with GetUserCommand — no admin + permissions needed, just the authenticated user's token. + + Returns dict with 'email' and 'name' keys, or None on failure. + """ + try: + response = self.cognito_client.get_user(AccessToken=access_token) + attrs = {a['Name']: a['Value'] for a in response.get('UserAttributes', [])} + return { + 'email': attrs.get('email'), + 'name': attrs.get('name') or attrs.get('given_name'), + 'cognito_user_id': attrs.get('sub'), + } + except ClientError as e: + error_code = e.response['Error']['Code'] + logger.warning("Cognito GetUser failed", + error_code=error_code, + event_type="cognito_get_user_error") + return None + except Exception as e: + logger.warning("Unexpected error fetching Cognito user", + error=str(e), + event_type="cognito_get_user_unexpected_error") + return None + async def delete_user(self, cognito_user_id: str) -> Dict[str, Any]: """ - Delete a user from Cognito User Pool - - Args: - cognito_user_id: The Cognito user's sub (UUID) - - Returns: - Dict with deletion status and details + Delete a user from Cognito User Pool (admin operation using task role). """ try: delete_logger = logger.bind(cognito_user_id=cognito_user_id) delete_logger.info("Attempting to delete Cognito user", - cognito_user_id=cognito_user_id, user_pool_id=settings.COGNITO_USER_POOL_ID, event_type="cognito_user_deletion_start") - # Delete the user from Cognito User Pool response = self.cognito_client.admin_delete_user( UserPoolId=settings.COGNITO_USER_POOL_ID, Username=cognito_user_id ) delete_logger.info("Successfully deleted Cognito user", - cognito_user_id=cognito_user_id, event_type="cognito_user_deleted") return { @@ -83,11 +99,10 @@ async def delete_user(self, cognito_user_id: str) -> Dict[str, Any]: if error_code == 'UserNotFoundException': delete_logger.warning("Cognito user not found for deletion", - cognito_user_id=cognito_user_id, error_code=error_code, event_type="cognito_user_not_found") return { - "success": True, # Consider this successful since user doesn't exist + "success": True, "cognito_user_id": cognito_user_id, "message": "User not found in Cognito (may have been already deleted)", "warning": True @@ -95,7 +110,6 @@ async def delete_user(self, cognito_user_id: str) -> Dict[str, Any]: elif error_code == 'InvalidParameterException': delete_logger.error("Invalid parameter for Cognito deletion", - cognito_user_id=cognito_user_id, error_code=error_code, error_message=error_message, event_type="cognito_invalid_parameter") @@ -108,7 +122,6 @@ async def delete_user(self, cognito_user_id: str) -> Dict[str, Any]: else: delete_logger.error("Cognito deletion failed", - cognito_user_id=cognito_user_id, error_code=error_code, error_message=error_message, event_type="cognito_deletion_failed") @@ -121,7 +134,6 @@ async def delete_user(self, cognito_user_id: str) -> Dict[str, Any]: except Exception as e: delete_logger.error("Unexpected error during Cognito deletion", - cognito_user_id=cognito_user_id, error=str(e), event_type="cognito_deletion_unexpected_error") return { @@ -130,71 +142,5 @@ async def delete_user(self, cognito_user_id: str) -> Dict[str, Any]: "error": f"Unexpected error: {str(e)}", "error_code": "UnknownError" } - - async def get_user_info(self, cognito_user_id: str) -> Optional[Dict[str, Any]]: - """ - Get user information from Cognito - - Args: - cognito_user_id: The Cognito user's sub (UUID) - - Returns: - User information dict or None if not found - """ - try: - info_logger = logger.bind(cognito_user_id=cognito_user_id) - info_logger.debug("Fetching user info from Cognito", - cognito_user_id=cognito_user_id, - user_pool_id=settings.COGNITO_USER_POOL_ID, - event_type="cognito_user_info_fetch_start") - - response = self.cognito_client.admin_get_user( - UserPoolId=settings.COGNITO_USER_POOL_ID, - Username=cognito_user_id - ) - - # Parse user attributes - user_attributes = {} - for attr in response.get('UserAttributes', []): - user_attributes[attr['Name']] = attr['Value'] - - info_logger.info("Successfully retrieved Cognito user info", - cognito_user_id=cognito_user_id, - user_status=response.get('UserStatus'), - attribute_count=len(user_attributes), - has_email=bool(user_attributes.get('email')), - event_type="cognito_user_info_retrieved") - - return { - "cognito_user_id": cognito_user_id, - "username": response.get('Username'), - "user_status": response.get('UserStatus'), - "enabled": response.get('Enabled'), - "user_create_date": response.get('UserCreateDate'), - "user_last_modified_date": response.get('UserLastModifiedDate'), - "attributes": user_attributes - } - - except ClientError as e: - error_code = e.response['Error']['Code'] - if error_code == 'UserNotFoundException': - info_logger.info("Cognito user not found", - cognito_user_id=cognito_user_id, - error_code=error_code, - event_type="cognito_user_not_found_info") - return None - info_logger.error("Error fetching Cognito user info", - cognito_user_id=cognito_user_id, - error=str(e), - error_code=error_code, - event_type="cognito_user_info_error") - return None - except Exception as e: - info_logger.error("Unexpected error fetching Cognito user info", - cognito_user_id=cognito_user_id, - error=str(e), - event_type="cognito_user_info_unexpected_error") - return None -# Global service instance -cognito_service = CognitoUserService() \ No newline at end of file +cognito_service = CognitoUserService()