diff --git a/.env.example b/.env.example index d96c5eb8..c55b2436 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,8 @@ ADMIN_ROLES='["Admin", "GalaxyAdmin"]' ENABLE_ADMIN_DASHBOARD=False # AAI Portal URL for admin links in emails AAI_PORTAL_URL=https://aaiportal.example.com +# AAF login proxy +AAI_LOGIN_PROXY_URL=https://aafproxy.example.com # URL of Galaxy instance, for making calls to Galaxy API GALAXY_URL=https://galaxy.example.com GALAXY_API_KEY=api-key diff --git a/config.py b/config.py index 86b01f3e..e7c8b9eb 100644 --- a/config.py +++ b/config.py @@ -27,6 +27,8 @@ class Settings(BaseSettings): cors_allowed_origins: str # AAI Portal URL for admin links in emails aai_portal_url: str = "" + # AAF login proxy: used to check if email addresses belong to AAF + aai_login_proxy_url: str = "" # Sender override for emails that should come from a no-reply address no_reply_email_sender: EmailStr # SES resource ARN for sending emails @@ -46,7 +48,7 @@ def normalize_environment(cls, value: str | None) -> Literal["dev", "staging", " return "production" return normalized - @field_validator('auth0_custom_domain', mode="after") + @field_validator('auth0_custom_domain', 'aai_login_proxy_url', mode="after") @classmethod def strip_trailing_slash(cls, value: str | None) -> str | None: if value is None: @@ -78,6 +80,21 @@ def set_default_aai_portal_url(self) -> "Settings": self.aai_portal_url = default_url return self + @model_validator(mode="after") + def set_default_aai_login_proxy_url(self) -> "Settings": + if self.aai_login_proxy_url: + return self + env_to_url = { + "dev-aaf": "https://dev-aaf.aaf-login.aai.test.biocommons.org.au" + } + default_url = env_to_url.get(self.environment) + if not default_url: + raise ValueError( + "Unknown ENVIRONMENT value and AAI_LOGIN_PROXY_URL is not set." + ) + self.aai_login_proxy_url = default_url + return self + @lru_cache() def get_settings(): diff --git a/register/utils.py b/register/utils.py new file mode 100644 index 00000000..5cdfcaad --- /dev/null +++ b/register/utils.py @@ -0,0 +1,239 @@ +import logging +from typing import Optional + +from sqlmodel import Session + +from auth0.client import Auth0Client +from biocommons.bundles import BUNDLES, BiocommonsBundle +from biocommons.default import get_default_platforms +from biocommons.emails import ( + compose_bundle_request_confirmation_email, + compose_group_approval_email, + format_first_name, + get_group_admin_contacts, + get_requester_identity, +) +from config import Settings +from db.models import BiocommonsUser, BiocommonsUserHistory, GroupMembership +from schemas.biocommons import Auth0UserData +from schemas.biocommons_register import BundleRequest +from schemas.responses import FieldError, RegistrationErrorResponse +from services.email_queue import enqueue_email +from services.institutions import is_australian_research_institution_email + +logger = logging.getLogger("uvicorn.error") + + +def check_is_username_used(username: str, session: Session) -> RegistrationErrorResponse | None: + username_used = BiocommonsUserHistory.is_username_used(username, session=session) + if username_used: + field_errors = [FieldError(field="username", message="Username is already taken")] + error_response = RegistrationErrorResponse( + message="Username is already taken", + field_errors=field_errors, + ) + return error_response + return None + + +def process_bundle_request_notifications( + bundles: list[BundleRequest], + db_user: BiocommonsUser, + auth0_user_data: Auth0UserData, + auth0_client: Auth0Client, + db_session: Session, + settings: Settings,): + """ + Send any admin/requester notifications that are needed based on requested bundles + Bundles that are auto-approved don't need notifications + """ + for bundle_request in bundles: + bundle = BUNDLES[bundle_request.bundle_id] + if bundle.group_auto_approve: + continue + _notify_bundle_group_admins( + bundle=bundle, + user=db_user, + auth0_client=auth0_client, + db_session=db_session, + settings=settings, + ) + _notify_bundle_requester( + bundle=bundle, + user=db_user, + auth0_user_data=auth0_user_data, + db_session=db_session, + settings=settings, + request_reason=bundle_request.reason, + ) + + +def _notify_bundle_group_admins( + *, + bundle: BiocommonsBundle, + user: BiocommonsUser, + auth0_client: Auth0Client, + db_session: Session, + settings: Settings, +) -> None: + """ + Queue approval emails for bundle group admins when memberships require review. + """ + if bundle.group_auto_approve: + return + + membership = GroupMembership.get_by_user_id_and_group_id( + user_id=user.id, + group_id=bundle.group_id.value, + session=db_session, + ) + if membership is None: + logger.warning( + "Unable to find group membership for user %s and bundle %s", + user.id, + bundle.id, + ) + return + + db_session.refresh(membership, attribute_names=["group", "user"]) + + admin_contacts = get_group_admin_contacts(group=membership.group, auth0_client=auth0_client) + if not admin_contacts: + logger.info("No admins found for group %s; skipping notification", membership.group_id) + return + + try: + requester_email, requester_full_name = get_requester_identity( + auth0_client=auth0_client, + user_id=membership.user_id, + fallback_email=membership.user.email, + ) + except Exception as exc: + logger.warning( + "Failed to fetch Auth0 user data for %s; using fallback values: %s", + membership.user_id, + exc, + ) + requester_email = membership.user.email + requester_full_name = requester_email or "Unknown user" + for email, admin_first_name in admin_contacts: + subject, body_html = compose_group_approval_email( + admin_first_name=admin_first_name, + bundle_name=membership.group.name, + requester_full_name=requester_full_name, + requester_email=requester_email, + request_reason=membership.request_reason, + settings=settings, + ) + enqueue_email( + db_session, + to_address=email, + subject=subject, + body_html=body_html, + settings=settings, + ) + + +def _notify_bundle_requester( + *, + bundle: BiocommonsBundle, + user: BiocommonsUser, + auth0_user_data: Auth0UserData, + db_session: Session, + settings: Settings, + request_reason: Optional[str], +) -> None: + """ + Queue a confirmation email to the user after they request bundle access. + """ + if bundle.group_auto_approve: + return + + membership = GroupMembership.get_by_user_id_and_group_id( + user_id=user.id, + group_id=bundle.group_id.value, + session=db_session, + ) + if membership is None: + logger.warning( + "Unable to find group membership for user %s and bundle %s", + user.id, + bundle.id, + ) + return + + db_session.refresh(membership, attribute_names=["group"]) + + first_name = format_first_name( + full_name=auth0_user_data.name, + given_name=auth0_user_data.given_name, + fallback="there", + ) + subject, body_html = compose_bundle_request_confirmation_email( + first_name=first_name, + bundle_name=membership.group.name, + request_reason=request_reason, + settings=settings, + ) + enqueue_email( + db_session, + to_address=str(auth0_user_data.email), + subject=subject, + body_html=body_html, + settings=settings, + ) + + +async def check_sbp_email_allowed(email: str, bundles: list[BundleRequest] | None) -> RegistrationErrorResponse | None: + """ + If user requests SBP access, check if the email domain is allowed. + + Return a RegistrationErrorResponse if SBP is requested and domain is not allowed, otherwise None. + """ + if bundles is not None: + has_sbp_bundle = any(bundle.bundle_id == "sbp_workflow_execution" for bundle in bundles) + if has_sbp_bundle: + is_institute = await is_australian_research_institution_email(email) + if not is_institute: + return RegistrationErrorResponse( + message="SBP workflow execution requires an Australian institutional email address.", + field_errors=[ + FieldError( + field="email", + message="Please use an Australian institutional email address if applying for SBP workflow execution access.", + ) + ], + ) + else: + return None + return None + + +def create_platform_memberships(db_user: BiocommonsUser, auth0_client: Auth0Client, session: Session, sbp_enabled: bool = True) -> None: + """ + Create default platform memberships (database record and Auth0 role) for a user + """ + for platform in get_default_platforms(sbp_enabled=sbp_enabled): + db_user.add_platform_membership( + platform=platform, + db_session=session, + auth0_client=auth0_client, + auto_approve=True + ) + + +def create_bundle_requests(bundles: list[BundleRequest] | None, db_user: BiocommonsUser, auth0_client: Auth0Client, session: Session) -> None: + """ + Create bundle requests for a user, based on the bundles requested at registration + """ + if bundles is not None: + for bundle_request in bundles: + bundle = BUNDLES[bundle_request.bundle_id] + logger.info(f"Adding group/platform memberships for bundle: {bundle}") + bundle.create_memberships( + user=db_user, + auth0_client=auth0_client, + db_session=session, + commit=False, + request_reason=bundle_request.reason, + ) diff --git a/routers/biocommons_register.py b/routers/biocommons_register.py index 7eb89a37..8c2ab6b3 100644 --- a/routers/biocommons_register.py +++ b/routers/biocommons_register.py @@ -1,35 +1,46 @@ import logging -from typing import Optional +from http import HTTPStatus +from typing import Annotated, Optional -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException from httpx2 import HTTPStatusError from sqlmodel import Session +from starlette import status +from starlette.responses import Response -from auth0.client import Auth0Client, get_auth0_client -from biocommons.bundles import BUNDLES, BiocommonsBundle -from biocommons.default import get_default_platforms -from biocommons.emails import ( - compose_bundle_request_confirmation_email, - compose_group_approval_email, - format_first_name, - get_group_admin_contacts, - get_requester_identity, -) +from auth.validator import verify_action_token +from auth0.client import Auth0Client, UpdateUserData, get_auth0_client from config import Settings, get_settings -from db.models import BiocommonsUser, BiocommonsUserHistory, GroupMembership +from db.models import BiocommonsUser from db.setup import get_db_session from register.tokens import validate_recaptcha +from register.utils import ( + check_is_username_used, + check_sbp_email_allowed, + create_bundle_requests, + create_platform_memberships, + process_bundle_request_notifications, +) from routers.errors import RegistrationRoute from routers.utils import check_existing_user -from schemas.biocommons import Auth0UserData, BiocommonsRegisterData -from schemas.biocommons_register import BiocommonsRegistrationRequest, BundleRequest +from schemas.auth0 import AafRegistrationActionToken +from schemas.biocommons import ( + Auth0UserData, + BiocommonsAppMetadataUpdate, + BiocommonsRegisterData, + BiocommonsUserAccountType, +) +from schemas.biocommons_register import ( + AafRegistrationRequest, + BiocommonsRegistrationRequest, + BundleRequest, +) from schemas.responses import ( FieldError, RegistrationErrorResponse, RegistrationResponse, ) -from services.email_queue import enqueue_email -from services.institutions import is_australian_research_institution_email +from services.institutions import is_aaf_email logger = logging.getLogger("uvicorn.error") @@ -50,166 +61,20 @@ def create_user_in_db(user_data: Auth0UserData, db_user = BiocommonsUser.from_auth0_data(data=user_data) session.add(db_user) session.flush() - for platform in get_default_platforms(sbp_enabled=sbp_enabled): - db_user.add_platform_membership( - platform=platform, - db_session=session, - auth0_client=auth0_client, - auto_approve=True - ) - - if bundles is not None: - for bundle_request in bundles: - bundle = BUNDLES[bundle_request.bundle_id] - logger.info(f"Adding group/platform memberships for bundle: {bundle}") - bundle.create_memberships( - user=db_user, - auth0_client=auth0_client, - db_session=session, - commit=False, - request_reason=bundle_request.reason, - ) - + # Add default platform memberships + create_platform_memberships(db_user=db_user, auth0_client=auth0_client, session=session, sbp_enabled=sbp_enabled) + # Create requests for selected bundles (if any) + create_bundle_requests(bundles=bundles, db_user=db_user, session=session, auth0_client=auth0_client) session.flush() if commit: session.commit() return db_user -def _notify_bundle_group_admins( - *, - bundle: BiocommonsBundle, - user: BiocommonsUser, - auth0_client: Auth0Client, - db_session: Session, - settings: Settings, -) -> None: - """ - Queue approval emails for bundle group admins when memberships require review. - """ - if bundle.group_auto_approve: - return - - membership = GroupMembership.get_by_user_id_and_group_id( - user_id=user.id, - group_id=bundle.group_id.value, - session=db_session, - ) - if membership is None: - logger.warning( - "Unable to find group membership for user %s and bundle %s", - user.id, - bundle.id, - ) - return - - db_session.refresh(membership, attribute_names=["group", "user"]) - - admin_contacts = get_group_admin_contacts(group=membership.group, auth0_client=auth0_client) - if not admin_contacts: - logger.info("No admins found for group %s; skipping notification", membership.group_id) - return - - try: - requester_email, requester_full_name = get_requester_identity( - auth0_client=auth0_client, - user_id=membership.user_id, - fallback_email=membership.user.email, - ) - except Exception as exc: - logger.warning( - "Failed to fetch Auth0 user data for %s; using fallback values: %s", - membership.user_id, - exc, - ) - requester_email = membership.user.email - requester_full_name = requester_email or "Unknown user" - for email, admin_first_name in admin_contacts: - subject, body_html = compose_group_approval_email( - admin_first_name=admin_first_name, - bundle_name=membership.group.name, - requester_full_name=requester_full_name, - requester_email=requester_email, - request_reason=membership.request_reason, - settings=settings, - ) - enqueue_email( - db_session, - to_address=email, - subject=subject, - body_html=body_html, - settings=settings, - ) - - -def _notify_bundle_requester( - *, - bundle: BiocommonsBundle, - user: BiocommonsUser, - auth0_user_data: Auth0UserData, - db_session: Session, - settings: Settings, - request_reason: Optional[str], -) -> None: - """ - Queue a confirmation email to the user after they request bundle access. - """ - if bundle.group_auto_approve: - return - - membership = GroupMembership.get_by_user_id_and_group_id( - user_id=user.id, - group_id=bundle.group_id.value, - session=db_session, - ) - if membership is None: - logger.warning( - "Unable to find group membership for user %s and bundle %s", - user.id, - bundle.id, - ) - return - - db_session.refresh(membership, attribute_names=["group"]) - - first_name = format_first_name( - full_name=auth0_user_data.name, - given_name=auth0_user_data.given_name, - fallback="there", - ) - subject, body_html = compose_bundle_request_confirmation_email( - first_name=first_name, - bundle_name=membership.group.name, - request_reason=request_reason, - settings=settings, - ) - enqueue_email( - db_session, - to_address=str(auth0_user_data.email), - subject=subject, - body_html=body_html, - settings=settings, - ) - - -async def check_sbp_email_domain(registration: BiocommonsRegistrationRequest) -> bool: - """ - If user requests SBP access, check if the email domain is allowed. - - Return false if the email domain is not allowed. - """ - if registration.bundles is not None: - has_sbp_bundle = any(bundle.bundle_id == "sbp_workflow_execution" for bundle in registration.bundles) - if has_sbp_bundle: - is_institute = await is_australian_research_institution_email(registration.email) - return is_institute - return True - - -def _requests_sbp_bundle(registration: BiocommonsRegistrationRequest) -> bool: - if registration.bundles is None: +def _requests_sbp_bundle(bundles: list[BundleRequest] | None) -> bool: + if bundles is None: return False - return any(bundle.bundle_id == "sbp_workflow_execution" for bundle in registration.bundles) + return any(bundle.bundle_id == "sbp_workflow_execution" for bundle in bundles) @router.post( "/register", @@ -235,36 +100,23 @@ async def register_biocommons_user( response.status_code = 400 return RegistrationErrorResponse(message="Invalid recaptcha token, please try again") - if _requests_sbp_bundle(registration) and not settings.sbp_enabled: + if _requests_sbp_bundle(registration.bundles) and not settings.sbp_enabled: response.status_code = 400 return RegistrationErrorResponse(message="SBP workflow execution is currently unavailable.") # Pre-registration checks - email_ok = await check_sbp_email_domain(registration) - if not email_ok: + sbp_email_error = await check_sbp_email_allowed(email=registration.email, bundles=registration.bundles) + if sbp_email_error is not None: response.status_code = 400 - return RegistrationErrorResponse( - message="SBP workflow execution requires an Australian institutional email address.", - field_errors=[ - FieldError( - field="email", - message="Please use an Australian institutional email address if applying for SBP workflow execution access." - ) - ] - ) + return sbp_email_error # Create Auth0 user data user_data = BiocommonsRegisterData.from_biocommons_registration(registration) # Check if username has already been used previously - username_used = BiocommonsUserHistory.is_username_used(user_data.username, session=db_session) - if username_used: - field_errors = [FieldError(field="username", message="Username is already taken")] - error_response = RegistrationErrorResponse( - message="Username is already taken", - field_errors=field_errors - ) + duplicate_username_error = check_is_username_used(username=user_data.username, session=db_session) + if duplicate_username_error: response.status_code = 400 - return error_response + return duplicate_username_error try: logger.info("Registering user with Auth0") @@ -280,25 +132,14 @@ async def register_biocommons_user( ) if registration.bundles is not None: - for bundle_request in registration.bundles: - bundle = BUNDLES[bundle_request.bundle_id] - if bundle.group_auto_approve: - continue - _notify_bundle_group_admins( - bundle=bundle, - user=db_user, - auth0_client=auth0_client, - db_session=db_session, - settings=settings, - ) - _notify_bundle_requester( - bundle=bundle, - user=db_user, - auth0_user_data=auth0_user_data, - db_session=db_session, - settings=settings, - request_reason=bundle_request.reason, - ) + process_bundle_request_notifications( + bundles=registration.bundles, + db_user=db_user, + auth0_user_data=auth0_user_data, + auth0_client=auth0_client, + db_session=db_session, + settings=settings, + ) db_session.commit() @@ -344,3 +185,132 @@ async def register_biocommons_user( except Exception as e: logger.error(f"Unexpected error during registration: {e}") raise HTTPException(status_code=500, detail="Internal server error") + + +def create_aaf_user_in_db(register_data: AafRegistrationRequest, + *, + auth0_token: AafRegistrationActionToken, + auth0_client: Auth0Client, + session: Session, + settings: Settings, + commit: bool = False): + db_user = BiocommonsUser( + id=auth0_token.user_id, + email=auth0_token.email, + username=register_data.username, + # AAF users are considered email_verified by default + email_verified=True, + account_type=BiocommonsUserAccountType.AAF.value, + ) + session.add(db_user) + session.flush() + # Add default platform memberships + create_platform_memberships(db_user=db_user, auth0_client=auth0_client, session=session, sbp_enabled=settings.sbp_enabled) + # Create requests for selected bundles (if any) + create_bundle_requests(bundles=register_data.bundles, db_user=db_user, auth0_client=auth0_client, session=session) + session.flush() + if commit: + session.commit() + return db_user + + +def verify_registration_token(token: str, settings: Settings): + """ + Verify the token sent through from the original Auth0 action - we use this to provide + id, name + email so want to make sure it's verified + """ + payload = verify_action_token(token, settings) + if payload.get("purpose", None) != "aaf_registration": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token from Auth0 expired.") + return AafRegistrationActionToken(**payload) + + +@router.post("/register-aaf") +async def register_aaf( + register_data: AafRegistrationRequest, + response: Response, + session: Annotated[Session, Depends(get_db_session)], + auth0_client: Annotated[Auth0Client, Depends(get_auth0_client)], + settings: Annotated[Settings, Depends(get_settings)], +): + """ + Register an AAF user. Because we want to ensure we use the email and name provided by + the AAF, using a signed token passed through from Auth0 + """ + if not register_data.recaptcha_token: + response.status_code = status.HTTP_400_BAD_REQUEST + return RegistrationErrorResponse(message="Recaptcha token is required") + recaptcha_check = validate_recaptcha(register_data.recaptcha_token, settings=settings) + if not recaptcha_check: + response.status_code = status.HTTP_400_BAD_REQUEST + return RegistrationErrorResponse(message="Invalid recaptcha token, please try again") + + validated_token = verify_registration_token(register_data.session_token, settings=settings) + + if _requests_sbp_bundle(register_data.bundles) and not settings.sbp_enabled: + response.status_code = status.HTTP_400_BAD_REQUEST + return RegistrationErrorResponse(message="SBP workflow execution is currently unavailable.") + + is_aaf = is_aaf_email(validated_token.email, settings=settings) + if not is_aaf: + raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_CONTENT, detail=f"{validated_token.email} is not an AAF-associated email.") + + sbp_email_error = await check_sbp_email_allowed(email=validated_token.email, bundles=register_data.bundles) + if sbp_email_error is not None: + response.status_code = status.HTTP_400_BAD_REQUEST + return sbp_email_error + + duplicate_username_error = check_is_username_used(username=register_data.username, session=session) + if duplicate_username_error: + response.status_code = status.HTTP_400_BAD_REQUEST + return duplicate_username_error + + try: + logger.info("Setting username in app_metadata") + update_data = UpdateUserData( + app_metadata=BiocommonsAppMetadataUpdate( + username=register_data.username, + account_type=BiocommonsUserAccountType.AAF, + aaf_only=True, + ) + ) + try: + auth0_user_data = auth0_client.update_user(user_id=validated_token.user_id, update_data=update_data) + except ValueError as e: + logger.error(f"AAF registration failed: {e}") + response.status_code = status.HTTP_400_BAD_REQUEST + return RegistrationErrorResponse(message=f"AAF registration failed - couldn't update app_metadata: {e}") + logger.info("Adding user to database...") + db_user = create_aaf_user_in_db( + register_data=register_data, + auth0_token=validated_token, + auth0_client=auth0_client, + session=session, + settings=settings, + commit=False, + ) + if register_data.bundles is not None: + process_bundle_request_notifications( + bundles=register_data.bundles, + db_user=db_user, + auth0_user_data=auth0_user_data, + auth0_client=auth0_client, + db_session=session, + settings=settings + ) + + session.commit() + logger.info("Successfully added user to database.") + return { + "message": "User registered successfully", + "user": auth0_user_data + } + except HTTPStatusError as e: + logger.error(f"AAF registration failed: {e}") + # NOTE: don't think the checks of specific Auth0 issues are relevant here as we + # aren't registering in Auth0 + response.status_code = status.HTTP_400_BAD_REQUEST + return RegistrationErrorResponse(message=f"AAF registration failed: {e.response.text}") + except Exception as e: + logger.error(f"Unexpected error during registration: {e}") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error") diff --git a/schemas/auth0.py b/schemas/auth0.py index c44f7414..638c9dff 100644 --- a/schemas/auth0.py +++ b/schemas/auth0.py @@ -32,3 +32,12 @@ class Auth0ActionToken(BaseModel): purpose: str sub: str | None = None iss: str | None = None + + +class AafRegistrationActionToken(Auth0ActionToken): + """ + Extends Auth0ActionToken with extra fields used during AAF registration. + """ + given_name: str | None = None + family_name: str | None = None + name: str | None = None diff --git a/schemas/biocommons.py b/schemas/biocommons.py index 79eadcb7..9550d7d3 100644 --- a/schemas/biocommons.py +++ b/schemas/biocommons.py @@ -189,6 +189,7 @@ class BiocommonsAppMetadata(BaseModel): Note we expect all app_metadata from Auth0 to match this format (if not empty). """ + username: Optional[str] = None registration_from: Optional[AppId] = None old_emails: Optional[list[OldEmailRecord]] = None user_needs_migration: Optional[bool] = None @@ -218,6 +219,7 @@ class BiocommonsAppMetadataUpdate(BaseModel): Auth0 merges app_metadata fields when updating, so updates should not need to repeat fields that are not changing. """ + username: Optional[str] = None registration_from: Optional[AppId] = None old_emails: Optional[list[OldEmailRecord]] = None user_needs_migration: Optional[bool] = None diff --git a/schemas/biocommons_register.py b/schemas/biocommons_register.py index 1b3c991e..b828299c 100644 --- a/schemas/biocommons_register.py +++ b/schemas/biocommons_register.py @@ -21,3 +21,14 @@ class BiocommonsRegistrationRequest(BaseModel): recaptcha_token: Optional[str] = None model_config = ConfigDict(extra="forbid") + + +class AafRegistrationRequest(BaseModel): + """ + email and name come from AAF and are encoded in the session_token, + which needs to be verified. + """ + session_token: str + username: BiocommonsUsername + bundles: Optional[list[BundleRequest]] = None + recaptcha_token: Optional[str] = None diff --git a/services/institutions.py b/services/institutions.py index 4ef0103a..0a01da5f 100644 --- a/services/institutions.py +++ b/services/institutions.py @@ -1,6 +1,9 @@ from typing import Optional import httpx2 +from pydantic import BaseModel + +from config import Settings GALAXY_AU_VALIDATE_URL = "https://site.usegalaxy.org.au/institution/validate" @@ -28,3 +31,16 @@ async def is_australian_research_institution_email(email: str) -> bool: blocked by an upstream outage. """ return await check_australian_research_institution_email(email) is True + + +class AafCheckEmailResponse(BaseModel): + email: str + is_aaf: bool + + +def is_aaf_email(email: str, settings: Settings) -> bool: + check_email_url = settings.aai_login_proxy_url + "/aaf/email-check" + resp = httpx2.get(check_email_url, params={"email": email}) + resp.raise_for_status() + data = AafCheckEmailResponse.model_validate_json(resp.content) + return data.is_aaf diff --git a/tests/conftest.py b/tests/conftest.py index ecdf9948..330ff01d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -191,6 +191,7 @@ def mock_settings(): auth0_db_connection="Username-Password-Authentication", jwt_secret_key="mock-secret-key", cors_allowed_origins="https://test", + aai_login_proxy_url="https://mock-proxy-url", admin_roles=["Admin"], auth0_algorithms=["RS256"], recaptcha_secret="mock-secret", diff --git a/tests/datagen.py b/tests/datagen.py index 44ace3b1..1d30f8e0 100644 --- a/tests/datagen.py +++ b/tests/datagen.py @@ -124,6 +124,7 @@ def user_id(cls) -> str: username = BiocommonsProviders.biocommons_username # blocked = False by default blocked = False + email_verified = True class Auth0ConnectionFactory(ModelFactory[Auth0Connection]): diff --git a/tests/test_aaf.py b/tests/test_aaf.py index 42478e48..7f099fe7 100644 --- a/tests/test_aaf.py +++ b/tests/test_aaf.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta from http import HTTPStatus from unittest.mock import MagicMock from urllib.parse import parse_qs, urlparse @@ -8,6 +9,7 @@ from auth0.client import Auth0Client from routers.aaf import link_aaf_account, mark_user_aaf_only +from routers.biocommons_register import verify_registration_token from schemas.biocommons import Auth0Identity, BiocommonsUserAccountType from tests.datagen import Auth0UserDataFactory, random_auth0_id from tests.db.datagen import BiocommonsUserFactory @@ -159,6 +161,20 @@ def _action_token_payload( return payload +def _aaf_registration_token_payload( + user_id: str, + email: str, + purpose: str = "aaf_registration", +) -> dict: + return { + **_action_token_payload(user_id=user_id, email=email, purpose=purpose), + "given_name": "AAF", + "family_name": "User", + "name": "AAF User", + "exp": int((datetime.now() + timedelta(minutes=5)).timestamp()), + } + + def _assert_marked_aaf_only(update_user_mock, aaf_user_id: str, email: str): update_user_mock.assert_called_once() call_args, call_kwargs = update_user_mock.call_args @@ -209,6 +225,47 @@ def _decode_check_link_redirect( return decoded_token +def test_verify_registration_token_accepts_token_signed_with_settings_secret(mock_settings): + aaf_user_id = random_auth0_id() + payload = _aaf_registration_token_payload( + user_id=aaf_user_id, + email="aaf-user@example.edu.au", + ) + token = jwt.encode( + payload, + key=mock_settings.auth0_management_secret, + algorithm="HS256", + ) + + result = verify_registration_token(token, settings=mock_settings) + + assert result.user_id == aaf_user_id + assert str(result.email) == "aaf-user@example.edu.au" + assert result.client_id == "test-client" + assert result.purpose == "aaf_registration" + assert result.given_name == "AAF" + assert result.family_name == "User" + assert result.name == "AAF User" + + +def test_verify_registration_token_rejects_wrong_purpose(mock_settings): + token = jwt.encode( + _aaf_registration_token_payload( + user_id=random_auth0_id(), + email="aaf-user@example.edu.au", + purpose="aaf_link", + ), + key=mock_settings.auth0_management_secret, + algorithm="HS256", + ) + + with pytest.raises(HTTPException) as exc_info: + verify_registration_token(token, settings=mock_settings) + + assert exc_info.value.status_code == HTTPStatus.UNAUTHORIZED + assert exc_info.value.detail == "Token from Auth0 expired." + + def test_mark_user_aaf_only(): aaf_user_id = random_auth0_id() email = "aaf-user@example.com" @@ -423,3 +480,91 @@ def test_check_link_already_linked_is_idempotent(test_client, test_db_session, p get_user.assert_called_once_with(user_id=aaf_user_id) link_identity.assert_not_called() update_user.assert_not_called() + + +def test_register_aaf_returns_registration_error_when_auth0_update_fails( + test_client, + test_db_session, + mock_auth0_client, + mocker, +): + aaf_user_id = random_auth0_id() + email = "new-aaf-user@example.edu.au" + action_token_payload = _action_token_payload( + aaf_user_id, + email, + purpose="aaf_registration", + ) + action_token_payload.update( + { + "given_name": "New", + "family_name": "User", + "name": "New User", + } + ) + mocker.patch("routers.biocommons_register.verify_action_token", return_value=action_token_payload) + mocker.patch("routers.biocommons_register.validate_recaptcha", return_value=True) + mocker.patch("routers.biocommons_register.is_aaf_email", return_value=True) + mock_auth0_client.update_user.side_effect = ValueError("Failed to update user auth0|123: {'message': 'bad request'}") + + response = test_client.post( + "/biocommons/register-aaf", + json={ + "session_token": "valid_token", + "username": "new_aaf_user", + "recaptcha_token": "valid_recaptcha", + }, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert response.json() == { + "message": "AAF registration failed - couldn't update app_metadata: Failed to update user auth0|123: {'message': 'bad request'}", + "field_errors": [], + } + mock_auth0_client.update_user.assert_called_once() + + +def test_register_aaf_sbp_disabled_rejects_before_creating_user( + test_client, + mock_settings, + mock_auth0_client, + mocker, +): + mock_settings.sbp_enabled = False + aaf_user_id = random_auth0_id() + email = "researcher@unimelb.edu.au" + action_token_payload = _action_token_payload( + aaf_user_id, + email, + purpose="aaf_registration", + ) + action_token_payload.update( + { + "given_name": "SBP", + "family_name": "Disabled", + "name": "SBP Disabled", + } + ) + mocker.patch("routers.biocommons_register.verify_action_token", return_value=action_token_payload) + mocker.patch("routers.biocommons_register.validate_recaptcha", return_value=True) + is_aaf_email = mocker.patch("routers.biocommons_register.is_aaf_email", return_value=True) + institution_check = mocker.patch("register.utils.is_australian_research_institution_email") + + response = test_client.post( + "/biocommons/register-aaf", + json={ + "session_token": "valid_token", + "username": "sbp_disabled_user", + "bundles": [{"bundle_id": "sbp_workflow_execution", "reason": "SBP access"}], + "recaptcha_token": "valid_recaptcha", + }, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert response.json() == { + "message": "SBP workflow execution is currently unavailable.", + "field_errors": [], + } + is_aaf_email.assert_not_called() + institution_check.assert_not_called() + mock_auth0_client.update_user.assert_not_called() diff --git a/tests/test_biocommons_register.py b/tests/test_biocommons_register.py index 186fef86..bf3f142f 100644 --- a/tests/test_biocommons_register.py +++ b/tests/test_biocommons_register.py @@ -10,13 +10,14 @@ from biocommons.default import get_default_platforms from db.models import BiocommonsUser, BiocommonsUserHistory, EmailNotification from db.types import ApprovalStatusEnum, EmailStatusEnum, GroupEnum, PlatformEnum -from routers.biocommons_register import check_sbp_email_domain, create_user_in_db -from schemas.biocommons import BiocommonsRegisterData +from routers.biocommons_register import create_user_in_db +from schemas.biocommons import BiocommonsRegisterData, BiocommonsUserAccountType from schemas.biocommons_register import BiocommonsRegistrationRequest, BundleRequest from tests.datagen import ( Auth0UserDataFactory, BiocommonsRegistrationRequestFactory, RoleUserDataFactory, + random_auth0_id, ) from tests.db.datagen import ( Auth0RoleFactory, @@ -95,6 +96,18 @@ def sbp_group(persistent_factories): ) +def _aaf_registration_token_payload(user_id: str, email: str) -> dict: + return { + "user_id": user_id, + "email": email, + "client_id": "test-client", + "purpose": "aaf_registration", + "given_name": "AAF", + "family_name": "User", + "name": "AAF User", + } + + def test_biocommons_registration_data_excludes_null_user_metadata(): """Test that user_metadata is excluded when None and basic Auth0 data is correct""" req = BiocommonsRegistrationRequest( @@ -174,87 +187,6 @@ def test_biocommons_registration_tsi_bundle(): assert dumped["app_metadata"].get("services", []) == [] -@pytest.mark.asyncio -async def test_check_sbp_email_domain_skips_check_without_bundles(mocker): - institution_check = mocker.patch( - "routers.biocommons_register.is_australian_research_institution_email", - new=AsyncMock(return_value=False), - ) - registration = BiocommonsRegistrationRequest( - first_name="No", - last_name="Bundle", - email="no.bundle@example.com", - username="no_bundle", - password="StrongPass1!", - bundles=None, - ) - - assert await check_sbp_email_domain(registration) is True - institution_check.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_check_sbp_email_domain_skips_check_without_sbp_bundle(mocker): - institution_check = mocker.patch( - "routers.biocommons_register.is_australian_research_institution_email", - new=AsyncMock(return_value=False), - ) - registration = BiocommonsRegistrationRequest( - first_name="TSI", - last_name="User", - email="tsi.user@example.com", - username="tsi_user", - password="StrongPass1!", - bundles=[BundleRequest(bundle_id="tsi", reason="TSI access")], - ) - - assert await check_sbp_email_domain(registration) is True - institution_check.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_check_sbp_email_domain_checks_sbp_bundle(mocker): - institution_check = mocker.patch( - "routers.biocommons_register.is_australian_research_institution_email", - new=AsyncMock(return_value=True), - ) - registration = BiocommonsRegistrationRequest( - first_name="SBP", - last_name="User", - email="sbp.user@unimelb.edu.au", - username="sbp_user", - password="StrongPass1!", - bundles=[BundleRequest(bundle_id="sbp_workflow_execution", reason="SBP access")], - ) - - assert await check_sbp_email_domain(registration) is True - institution_check.assert_awaited_once_with("sbp.user@unimelb.edu.au") - - -@pytest.mark.asyncio -async def test_check_sbp_email_domain_returns_false_when_sbp_domain_check_fails(mocker): - institution_check = mocker.patch( - "routers.biocommons_register.is_australian_research_institution_email", - new=AsyncMock(return_value=False), - ) - registration = BiocommonsRegistrationRequest( - first_name="SBP", - last_name="User", - email="sbp.user@example.com", - username="sbp_user", - password="StrongPass1!", - bundles=[ - BundleRequest(bundle_id="tsi", reason="TSI access"), - BundleRequest(bundle_id="sbp_workflow_execution", reason="SBP access"), - ], - ) - - assert await check_sbp_email_domain(registration) is False - institution_check.assert_awaited_once_with("sbp.user@example.com") - - - - def test_create_biocommons_user_record_tsi_bundle(test_db_session, mock_auth0_client, tsi_group, galaxy_platform, bpa_platform, sbp_platform, persistent_factories): """Test database record creation for tsi bundle""" from db.models import PlatformEnum @@ -435,6 +367,145 @@ def test_successful_biocommons_registration_endpoint( assert user_email.subject == "Your Threatened Species Initiative Service Bundle request has been received" +def test_register_aaf_endpoint_success_no_bundles( + test_client, + mock_settings, + mock_auth0_client, + galaxy_platform, + bpa_platform, + sbp_platform, + test_db_session, + mock_recaptcha_verify, + mocker, +): + """Test successful AAF registration without bundle requests.""" + mock_settings.sbp_enabled = True + aaf_user_id = random_auth0_id() + email = "aaf-user@example.edu.au" + username = "new_aaf_user" + auth0_data = Auth0UserDataFactory.build( + user_id=aaf_user_id, + email=email, + username=username, + name="AAF User", + ) + mock_auth0_client.update_user.return_value = auth0_data + mocker.patch( + "routers.biocommons_register.verify_action_token", + return_value=_aaf_registration_token_payload(aaf_user_id, email), + ) + is_aaf_email = mocker.patch("routers.biocommons_register.is_aaf_email", return_value=True) + + response = test_client.post( + "/biocommons/register-aaf", + json={ + "session_token": "valid_token", + "username": username, + "recaptcha_token": "mock-token", + }, + ) + + assert response.status_code == 200 + assert response.json()["message"] == "User registered successfully" + assert response.json()["user"]["user_id"] == aaf_user_id + assert mock_recaptcha_verify.called + is_aaf_email.assert_called_once_with(email, settings=mock_settings) + mock_auth0_client.update_user.assert_called_once() + update_call = mock_auth0_client.update_user.call_args + assert update_call.kwargs["user_id"] == aaf_user_id + app_metadata = update_call.kwargs["update_data"].app_metadata + assert app_metadata.username == username + assert app_metadata.account_type == BiocommonsUserAccountType.AAF + assert app_metadata.aaf_only is True + + db_user = test_db_session.get(BiocommonsUser, aaf_user_id) + assert db_user is not None + assert db_user.email == email + assert db_user.username == username + assert db_user.email_verified is True + assert db_user.account_type == BiocommonsUserAccountType.AAF + assert db_user.group_memberships == [] + + platform_ids = {membership.platform_id for membership in db_user.platform_memberships} + assert platform_ids == {PlatformEnum.BPA_DATA_PORTAL, PlatformEnum.GALAXY, PlatformEnum.SBP} + assert test_db_session.exec(select(EmailNotification)).all() == [] + + +def test_register_aaf_endpoint_success_with_bundles( + test_client_with_email, + mock_settings, + mock_auth0_client, + tsi_group, + galaxy_platform, + bpa_platform, + sbp_platform, + test_db_session, + mock_recaptcha_verify, + mocker, +): + """Test successful AAF registration with requested bundles.""" + mock_settings.sbp_enabled = True + aaf_user_id = random_auth0_id() + email = "bundle-aaf-user@example.edu.au" + username = "bundle_aaf_user" + auth0_data = Auth0UserDataFactory.build( + user_id=aaf_user_id, + email=email, + username=username, + name="Bundle AAF User", + ) + mock_auth0_client.update_user.return_value = auth0_data + admin_stub = RoleUserDataFactory.build(email="tsi.admin@example.com") + mock_auth0_client.get_all_role_users.return_value = [admin_stub] + mock_auth0_client.get_user.return_value = Auth0UserDataFactory.build( + user_id=admin_stub.user_id, + email=admin_stub.email, + ) + mocker.patch( + "routers.biocommons_register.verify_action_token", + return_value=_aaf_registration_token_payload(aaf_user_id, email), + ) + mocker.patch("routers.biocommons_register.is_aaf_email", return_value=True) + + response = test_client_with_email.post( + "/biocommons/register-aaf", + json={ + "session_token": "valid_token", + "username": username, + "bundles": [{"bundle_id": "tsi", "reason": "Need TSI access"}], + "recaptcha_token": "mock-token", + }, + ) + + assert response.status_code == 200 + assert response.json()["message"] == "User registered successfully" + update_call = mock_auth0_client.update_user.call_args + app_metadata = update_call.kwargs["update_data"].app_metadata + assert app_metadata.username == username + assert app_metadata.account_type == BiocommonsUserAccountType.AAF + assert app_metadata.aaf_only is True + + db_user = test_db_session.get(BiocommonsUser, aaf_user_id) + assert db_user is not None + assert db_user.account_type == BiocommonsUserAccountType.AAF + group_memberships = {membership.group_id: membership for membership in db_user.group_memberships} + assert set(group_memberships) == {GroupEnum.TSI.value} + assert group_memberships[GroupEnum.TSI.value].approval_status == ApprovalStatusEnum.PENDING + assert group_memberships[GroupEnum.TSI.value].request_reason == "Need TSI access" + + platform_ids = {membership.platform_id for membership in db_user.platform_memberships} + assert platform_ids == {PlatformEnum.BPA_DATA_PORTAL, PlatformEnum.GALAXY, PlatformEnum.SBP} + + queued_emails = test_db_session.exec(select(EmailNotification)).all() + assert len(queued_emails) == 2 + emails_by_address = {email_notification.to_address: email_notification for email_notification in queued_emails} + assert emails_by_address[admin_stub.email].subject == "Threatened Species Initiative Service Bundle request" + assert emails_by_address[email].subject == ( + "Your Threatened Species Initiative Service Bundle request has been received" + ) + assert all(email_notification.status == EmailStatusEnum.PENDING for email_notification in queued_emails) + + def test_biocommons_registration_endpoint_multiple_bundles( test_client_with_email, mock_settings, @@ -463,7 +534,7 @@ def test_biocommons_registration_endpoint_multiple_bundles( email=admin_stub.email, ) domain_check = mocker.patch( - "routers.biocommons_register.is_australian_research_institution_email", + "register.utils.is_australian_research_institution_email", new=AsyncMock(return_value=True), ) @@ -511,7 +582,7 @@ def test_biocommons_registration_endpoint_sbp_rejects_non_institutional_email( """Test SBP workflow registration checks the email domain before creating a user.""" mock_settings.sbp_enabled = True domain_check = mocker.patch( - "routers.biocommons_register.is_australian_research_institution_email", + "register.utils.is_australian_research_institution_email", new=AsyncMock(return_value=False), ) @@ -551,7 +622,7 @@ def test_biocommons_registration_endpoint_sbp_disabled_rejects_before_creating_u """Test SBP workflow registration is blocked by the feature flag.""" mock_settings.sbp_enabled = False domain_check = mocker.patch( - "routers.biocommons_register.is_australian_research_institution_email", + "register.utils.is_australian_research_institution_email", new=AsyncMock(return_value=True), ) diff --git a/tests/test_config.py b/tests/test_config.py index dd90ea0a..3d3f5f7c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -23,6 +23,7 @@ def _base_settings_kwargs(): "recaptcha_secret": "mock-recaptcha", "jwt_secret_key": "mock-secret-key", "cors_allowed_origins": "https://test", + "aai_login_proxy_url": "https://proxy", "no_reply_email_sender": "no-reply@example.com", } diff --git a/tests/test_register_utils.py b/tests/test_register_utils.py new file mode 100644 index 00000000..fbcfe082 --- /dev/null +++ b/tests/test_register_utils.py @@ -0,0 +1,61 @@ +from unittest.mock import AsyncMock + +import pytest + +from register.utils import check_sbp_email_allowed +from schemas.biocommons_register import BundleRequest + + +@pytest.mark.asyncio +async def test_check_sbp_email_allowed_skips_check_without_bundles(mocker): + institution_check = mocker.patch( + "register.utils.is_australian_research_institution_email", + new=AsyncMock(return_value=False), + ) + + assert await check_sbp_email_allowed(email="no.bundle@example.com", bundles=None) is None + institution_check.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_check_sbp_email_allowed_skips_check_without_sbp_bundle(mocker): + institution_check = mocker.patch( + "register.utils.is_australian_research_institution_email", + new=AsyncMock(return_value=False), + ) + bundles = [BundleRequest(bundle_id="tsi", reason="TSI access")] + + assert await check_sbp_email_allowed(email="tsi.user@example.com", bundles=bundles) is None + institution_check.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_check_sbp_email_allowed_checks_sbp_bundle(mocker): + institution_check = mocker.patch( + "register.utils.is_australian_research_institution_email", + new=AsyncMock(return_value=True), + ) + bundles = [BundleRequest(bundle_id="sbp_workflow_execution", reason="SBP access")] + + result = await check_sbp_email_allowed(email="sbp.user@unimelb.edu.au", bundles=bundles) + + assert result is None + institution_check.assert_awaited_once_with("sbp.user@unimelb.edu.au") + + +@pytest.mark.asyncio +async def test_check_sbp_email_allowed_returns_error_when_domain_check_fails(mocker): + institution_check = mocker.patch( + "register.utils.is_australian_research_institution_email", + new=AsyncMock(return_value=False), + ) + bundles = [ + BundleRequest(bundle_id="tsi", reason="TSI access"), + BundleRequest(bundle_id="sbp_workflow_execution", reason="SBP access"), + ] + + result = await check_sbp_email_allowed(email="sbp.user@example.com", bundles=bundles) + + assert result is not None + assert result.message == "SBP workflow execution requires an Australian institutional email address." + institution_check.assert_awaited_once_with("sbp.user@example.com") diff --git a/tests/test_utils.py b/tests/test_utils.py index ade16a40..33f88a81 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,7 +8,11 @@ from db.models import EmailNotification from main import app from routers import utils -from services.institutions import GALAXY_AU_VALIDATE_URL +from services.institutions import ( + GALAXY_AU_VALIDATE_URL, + AafCheckEmailResponse, + is_aaf_email, +) from tests.datagen import ( Auth0ReadAppMetadataFactory, Auth0UserDataFactory, @@ -242,3 +246,14 @@ def test_send_welcome_email_suppresses_duplicate(override_auth0_client, test_cli assert resp.json()["message"] == "Welcome email already queued or sent." queued = test_db_session.exec(select(EmailNotification)).all() assert len(queued) == 1 + + +@respx.mock +def test_is_aaf_email(mock_settings): + mock_resp = AafCheckEmailResponse(email="user@example.com", is_aaf=True) + url = f"{mock_settings.aai_login_proxy_url}/aaf/email-check" + route = respx.get(url, params={"email": "user@example.com"}).respond(200, json=mock_resp.model_dump(mode="json")) + + result = is_aaf_email(email="user@example.com", settings=mock_settings) + assert result is True + assert route.called