"""AWS Lambda handler for Auth0 user management.""" import logging import os from typing import Any import requests import sentry_sdk from config import Auth0Config from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration from shared.schemas import ( DeleteUserByIdEvent, DeleteUserByIdResponse, SearchUserByEmailEvent, SearchUserByEmailResponse, SuspendUserByIdEvent, SuspendUserByIdResponse, ) from shared.schemas.auth0 import Auth0Match from auth0_client.auth0_client import Auth0Client from auth0_client.secrets_manager import SecretsManagerClient sentry_sdk.init( dsn=os.environ.get('SENTRY_DSN'), environment=os.environ.get('ENVIRONMENT'), integrations=[AwsLambdaIntegration()], ) LOGGER = logging.getLogger(__name__) logging.basicConfig( level=logging.INFO, format='%(asctime)s %(name)s %(levelname)s: %(message)s' ) def handler(event: dict[str, Any], context: Any) -> dict[str, Any]: """Route Lambda event to the appropriate action handler. :param event: Lambda event dict with required 'action' and 'dry_run' keys :param context: Lambda context object (unused) :return: Action-specific response dict :raises ValueError: If action is unknown """ action = event.get('action') if action == 'search-user-by-email': return _handle_search_user_by_email( SearchUserByEmailEvent.model_validate(event) ) if action == 'delete-user-by-id': return _handle_delete_user_by_id(DeleteUserByIdEvent.model_validate(event)) if action == 'suspend-user-by-id': return _handle_suspend_user_by_id(SuspendUserByIdEvent.model_validate(event)) raise ValueError(f'Unknown action: {action!r}') def _handle_search_user_by_email(evt: SearchUserByEmailEvent) -> dict[str, Any]: """Search for a user by email across all configured Auth0 tenants. Per-tenant failures (missing secret, Auth0 API error) are absorbed: the tenant is logged and skipped, and remaining tenants are still searched. :param evt: Validated event containing 'email' and optional 'ticket_id' :return: {email, ticket_id, matches: [{tenant, user_id}]} """ cfg = Auth0Config() secrets = SecretsManagerClient() matches: list[Auth0Match] = [] for tenant in cfg.tenant_list: try: creds = secrets.get_tenant_credentials(tenant) except Exception as exc: LOGGER.error('Failed to fetch credentials for tenant "%s": %s', tenant, exc) continue client = Auth0Client(creds.domain, creds.client_id, creds.client_secret) try: users = client.search_user_by_email(evt.email) except Exception as exc: LOGGER.error( 'Failed to search for "%s" in tenant "%s": %s', evt.email, tenant, exc ) continue for user in users: user_id = user.get('user_id', '') if user_id: matches.append(Auth0Match(tenant=tenant, user_id=user_id)) LOGGER.info( 'Found user "%s" in tenant "%s" (user_id: %s)', evt.email, tenant, user_id, ) if not users: LOGGER.info( 'No user found in tenant "%s" with email: %s', tenant, evt.email ) return SearchUserByEmailResponse( email=evt.email, ticket_id=evt.ticket_id, matches=matches ).model_dump() def _handle_delete_user_by_id(evt: DeleteUserByIdEvent) -> dict[str, Any]: """Delete an Auth0 user by ID from a specific tenant. Auth0 404 (user already absent) is treated as a non-error idempotent outcome. :param evt: Validated event containing 'tenant', 'user_id', and optional 'ticket_id' :return: {tenant, user_id, ticket_id, deleted, dry_run} """ if evt.dry_run: LOGGER.info( '[Dry run] Would delete user "%s" from tenant "%s".', evt.user_id, evt.tenant, ) return DeleteUserByIdResponse( tenant=evt.tenant, user_id=evt.user_id, ticket_id=evt.ticket_id, deleted=False, dry_run=True, ).model_dump() secrets = SecretsManagerClient() try: creds = secrets.get_tenant_credentials(evt.tenant) except Exception as exc: LOGGER.error('Failed to fetch credentials for tenant "%s": %s', evt.tenant, exc) raise ValueError( f'Cannot delete user: failed to fetch credentials for tenant {evt.tenant!r}' ) from exc client = Auth0Client(creds.domain, creds.client_id, creds.client_secret) try: client.delete_user_by_id(evt.user_id) LOGGER.info('Deleted user "%s" from tenant "%s".', evt.user_id, evt.tenant) return DeleteUserByIdResponse( tenant=evt.tenant, user_id=evt.user_id, ticket_id=evt.ticket_id, deleted=True, dry_run=False, ).model_dump() except requests.exceptions.HTTPError as exc: if exc.response is not None and exc.response.status_code == 404: LOGGER.warning( 'User "%s" not found in tenant "%s" (already absent).', evt.user_id, evt.tenant, ) return DeleteUserByIdResponse( tenant=evt.tenant, user_id=evt.user_id, ticket_id=evt.ticket_id, deleted=False, dry_run=False, ).model_dump() raise def _handle_suspend_user_by_id(evt: SuspendUserByIdEvent) -> dict[str, Any]: """Suspend (block) an Auth0 user by ID in a specific tenant. Sets the Auth0 ``blocked`` flag to ``True``, disabling login while keeping the account intact for later reinstatement. Auth0 404 (user already absent) is treated as a non-error idempotent outcome. :param evt: Validated event containing 'tenant', 'user_id', and optional 'ticket_id' :return: {tenant, user_id, ticket_id, suspended, dry_run} """ if evt.dry_run: LOGGER.info( '[Dry run] Would suspend user "%s" in tenant "%s".', evt.user_id, evt.tenant, ) return SuspendUserByIdResponse( tenant=evt.tenant, user_id=evt.user_id, ticket_id=evt.ticket_id, suspended=False, dry_run=True, ).model_dump() secrets = SecretsManagerClient() try: creds = secrets.get_tenant_credentials(evt.tenant) except Exception as exc: LOGGER.error('Failed to fetch credentials for tenant "%s": %s', evt.tenant, exc) raise ValueError( f'Cannot suspend user: failed to fetch credentials for tenant {evt.tenant!r}' ) from exc client = Auth0Client(creds.domain, creds.client_id, creds.client_secret) try: client.block_user_by_id(evt.user_id) LOGGER.info('Suspended user "%s" in tenant "%s".', evt.user_id, evt.tenant) return SuspendUserByIdResponse( tenant=evt.tenant, user_id=evt.user_id, ticket_id=evt.ticket_id, suspended=True, dry_run=False, ).model_dump() except requests.exceptions.HTTPError as exc: if exc.response is not None and exc.response.status_code == 404: LOGGER.warning( 'User "%s" not found in tenant "%s" (already absent).', evt.user_id, evt.tenant, ) return SuspendUserByIdResponse( tenant=evt.tenant, user_id=evt.user_id, ticket_id=evt.ticket_id, suspended=False, dry_run=False, ).model_dump() raise