From a3be1e9ab472a4bce5e85d15fa5092e5be844eca Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 11:05:02 +1000 Subject: [PATCH 01/19] feat: add login proxy URL to settings - used to check AAF emails --- .env.example | 2 ++ config.py | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) 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(): From 1564feb8a001780f197383608047cd9512dedc8c Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 11:07:28 +1000 Subject: [PATCH 02/19] test: add proxy URL to mock settings for testing --- tests/conftest.py | 1 + tests/test_config.py | 1 + 2 files changed, 2 insertions(+) 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/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", } From cff28ccf94a1a81a82a8c4fe7ba5d7cc331a178d Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 11:09:50 +1000 Subject: [PATCH 03/19] feat: function to check if email is AAF --- services/institutions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/services/institutions.py b/services/institutions.py index 4ef0103a..c665b8bd 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,15 @@ 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 From eee8033b83a50835e533f8f30774e36d4a8b7ab0 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 11:33:57 +1000 Subject: [PATCH 04/19] test: unit test of is_aaf_email --- services/institutions.py | 1 + tests/test_utils.py | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/services/institutions.py b/services/institutions.py index c665b8bd..0a01da5f 100644 --- a/services/institutions.py +++ b/services/institutions.py @@ -37,6 +37,7 @@ 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}) 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 From f7074798394e0165443f49a39f49f1876541f669 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 11:39:34 +1000 Subject: [PATCH 05/19] refactor: move common registration logic to a shared module --- register/utils.py | 208 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 register/utils.py diff --git a/register/utils.py b/register/utils.py new file mode 100644 index 00000000..999b6d47 --- /dev/null +++ b/register/utils.py @@ -0,0 +1,208 @@ +import logging +from typing import Optional + +from sqlmodel import Session + +from auth0.client import Auth0Client +from biocommons.bundles import BUNDLES, BiocommonsBundle +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 From 696b3bf9f419a11b97cbf51cc8eac9bded78d559 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 11:45:55 +1000 Subject: [PATCH 06/19] refactor: use shared utils in existing register function --- routers/biocommons_register.py | 200 ++++----------------------------- 1 file changed, 21 insertions(+), 179 deletions(-) diff --git a/routers/biocommons_register.py b/routers/biocommons_register.py index 7eb89a37..379ff240 100644 --- a/routers/biocommons_register.py +++ b/routers/biocommons_register.py @@ -6,19 +6,17 @@ from sqlmodel import Session from auth0.client import Auth0Client, get_auth0_client -from biocommons.bundles import BUNDLES, BiocommonsBundle +from biocommons.bundles import BUNDLES 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, 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, + process_bundle_request_notifications, +) from routers.errors import RegistrationRoute from routers.utils import check_existing_user from schemas.biocommons import Auth0UserData, BiocommonsRegisterData @@ -28,8 +26,6 @@ RegistrationErrorResponse, RegistrationResponse, ) -from services.email_queue import enqueue_email -from services.institutions import is_australian_research_institution_email logger = logging.getLogger("uvicorn.error") @@ -76,136 +72,6 @@ def create_user_in_db(user_data: Auth0UserData, 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: return False @@ -240,31 +106,18 @@ async def register_biocommons_user( 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 +133,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() From 2cae0bde018a312f36348006b82c57a4d27d032c Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 11:52:15 +1000 Subject: [PATCH 07/19] feat: schemas for AAF registration/metadata --- schemas/auth0.py | 9 +++++++++ schemas/biocommons.py | 2 ++ schemas/biocommons_register.py | 11 +++++++++++ 3 files changed, 22 insertions(+) diff --git a/schemas/auth0.py b/schemas/auth0.py index c44f7414..8bacfddc 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 + family_name: str | None + name: str | 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 From 52b354efa22f07d9bb19414e010a10acc712c881 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 11:59:09 +1000 Subject: [PATCH 08/19] feat: initial implementation of AAF register endpoint --- routers/aaf.py | 130 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 3 deletions(-) diff --git a/routers/aaf.py b/routers/aaf.py index 231355a8..bc89ec5e 100644 --- a/routers/aaf.py +++ b/routers/aaf.py @@ -6,23 +6,35 @@ import httpx from fastapi import APIRouter, Depends, HTTPException +from httpx2 import HTTPStatusError from pydantic import BaseModel from sqlmodel import Session -from starlette.responses import RedirectResponse +from starlette import status +from starlette.responses import RedirectResponse, Response -from auth.validator import create_action_token +from auth.validator import create_action_token, verify_action_token from auth0.client import Auth0Client, UpdateUserData, get_auth0_client +from biocommons.default import get_default_platforms from config import Settings, get_settings from db.models import BiocommonsUser from db.setup import get_db_session from dependencies.auth import require_action_token -from schemas.auth0 import Auth0ActionToken +from register.tokens import validate_recaptcha +from register.utils import ( + check_is_username_used, + check_sbp_email_allowed, + process_bundle_request_notifications, +) +from schemas.auth0 import AafRegistrationActionToken, Auth0ActionToken from schemas.biocommons import ( Auth0Identity, Auth0UserData, BiocommonsAppMetadataUpdate, BiocommonsUserAccountType, ) +from schemas.biocommons_register import AafRegistrationRequest +from schemas.responses import RegistrationErrorResponse +from services.institutions import is_aaf_email router = APIRouter( prefix="/aaf", tags=["aaf"] @@ -210,3 +222,115 @@ def check_aaf_account_link( action_token=token, settings=settings, ) + + +def create_aaf_user_in_db(register_data: AafRegistrationRequest, + *, + auth0_token: AafRegistrationActionToken, + auth0_client: Auth0Client, + session: Session, + settings: Settings): + 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() + for platform in get_default_platforms(sbp_enabled=settings.sbp_enabled): + db_user.add_platform_membership( + platform=platform, + db_session=session, + auth0_client=auth0_client, + auto_approve=True + ) + 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") +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) + + 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)) + auth0_user_data = auth0_client.update_user(user_id=validated_token.user_id, update_data=update_data) + 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 + ) + 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") From ec783f4552cbdfb23706ab3918cd4c994d417e03 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 13:12:30 +1000 Subject: [PATCH 09/19] test: rework tests of sbp domain check --- tests/test_biocommons_register.py | 89 ++----------------------------- tests/test_register_utils.py | 61 +++++++++++++++++++++ 2 files changed, 65 insertions(+), 85 deletions(-) create mode 100644 tests/test_register_utils.py diff --git a/tests/test_biocommons_register.py b/tests/test_biocommons_register.py index 186fef86..302c0e3d 100644 --- a/tests/test_biocommons_register.py +++ b/tests/test_biocommons_register.py @@ -10,7 +10,7 @@ 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 routers.biocommons_register import create_user_in_db from schemas.biocommons import BiocommonsRegisterData from schemas.biocommons_register import BiocommonsRegistrationRequest, BundleRequest from tests.datagen import ( @@ -174,87 +174,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 @@ -463,7 +382,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 +430,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 +470,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_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") From acd67931b592f61e83bd3a85d93568348af3cded Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 13:52:41 +1000 Subject: [PATCH 10/19] refactor: move platform/bundle creation into shared utils --- register/utils.py | 31 +++++++++++++++++++++++++++++++ routers/biocommons_register.py | 28 ++++++---------------------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/register/utils.py b/register/utils.py index 999b6d47..5cdfcaad 100644 --- a/register/utils.py +++ b/register/utils.py @@ -5,6 +5,7 @@ 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, @@ -206,3 +207,33 @@ async def check_sbp_email_allowed(email: str, bundles: list[BundleRequest] | Non 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 379ff240..ebf50b9e 100644 --- a/routers/biocommons_register.py +++ b/routers/biocommons_register.py @@ -6,8 +6,6 @@ from sqlmodel import Session from auth0.client import Auth0Client, get_auth0_client -from biocommons.bundles import BUNDLES -from biocommons.default import get_default_platforms from config import Settings, get_settings from db.models import BiocommonsUser from db.setup import get_db_session @@ -15,6 +13,8 @@ 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 @@ -46,26 +46,10 @@ 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() From 9984b5bd0de52ffc048e95f862ebe54a8c368975 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 13:53:23 +1000 Subject: [PATCH 11/19] fix: catch error when updating Auth0 metadata --- routers/aaf.py | 7 ++++++- tests/test_aaf.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/routers/aaf.py b/routers/aaf.py index bc89ec5e..a54c62b8 100644 --- a/routers/aaf.py +++ b/routers/aaf.py @@ -300,7 +300,12 @@ async def register_aaf( try: logger.info("Setting username in app_metadata") update_data = UpdateUserData(app_metadata=BiocommonsAppMetadataUpdate(username=register_data.username)) - auth0_user_data = auth0_client.update_user(user_id=validated_token.user_id, update_data=update_data) + 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, diff --git a/tests/test_aaf.py b/tests/test_aaf.py index 42478e48..c62736a5 100644 --- a/tests/test_aaf.py +++ b/tests/test_aaf.py @@ -423,3 +423,47 @@ 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, + 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.aaf.verify_action_token", return_value=action_token_payload) + mocker.patch("routers.aaf.validate_recaptcha", return_value=True) + mocker.patch("routers.aaf.is_aaf_email", return_value=True) + update_user = mocker.patch( + "routers.aaf.Auth0Client.update_user", + side_effect=ValueError("Failed to update user auth0|123: {'message': 'bad request'}"), + ) + + response = test_client.post( + "/aaf/register", + 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: Failed to update user auth0|123: {'message': 'bad request'}", + "field_errors": [], + } + update_user.assert_called_once() From 3053130f2266a277d2f64c364de70d971e4d6c3d Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 13:55:18 +1000 Subject: [PATCH 12/19] refactor: use bundle/platform utils in AAF register function --- routers/aaf.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/routers/aaf.py b/routers/aaf.py index a54c62b8..8b6941d7 100644 --- a/routers/aaf.py +++ b/routers/aaf.py @@ -14,7 +14,6 @@ from auth.validator import create_action_token, verify_action_token from auth0.client import Auth0Client, UpdateUserData, get_auth0_client -from biocommons.default import get_default_platforms from config import Settings, get_settings from db.models import BiocommonsUser from db.setup import get_db_session @@ -23,6 +22,8 @@ from register.utils import ( check_is_username_used, check_sbp_email_allowed, + create_bundle_requests, + create_platform_memberships, process_bundle_request_notifications, ) from schemas.auth0 import AafRegistrationActionToken, Auth0ActionToken @@ -229,7 +230,8 @@ def create_aaf_user_in_db(register_data: AafRegistrationRequest, auth0_token: AafRegistrationActionToken, auth0_client: Auth0Client, session: Session, - settings: Settings): + settings: Settings, + commit: bool = False): db_user = BiocommonsUser( id=auth0_token.user_id, email=auth0_token.email, @@ -240,13 +242,13 @@ def create_aaf_user_in_db(register_data: AafRegistrationRequest, ) session.add(db_user) session.flush() - for platform in get_default_platforms(sbp_enabled=settings.sbp_enabled): - db_user.add_platform_membership( - platform=platform, - db_session=session, - auth0_client=auth0_client, - auto_approve=True - ) + # 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 @@ -312,7 +314,8 @@ async def register_aaf( auth0_token=validated_token, auth0_client=auth0_client, session=session, - settings=settings + settings=settings, + commit=False, ) if register_data.bundles is not None: process_bundle_request_notifications( From 54ea09f9998d99a8fc6a0128cf0b49130957dc68 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 15:56:47 +1000 Subject: [PATCH 13/19] fix: block requests for SBP if not enabled --- routers/aaf.py | 10 ++++++++++ tests/test_aaf.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/routers/aaf.py b/routers/aaf.py index 8b6941d7..9319fef5 100644 --- a/routers/aaf.py +++ b/routers/aaf.py @@ -263,6 +263,12 @@ def verify_registration_token(token: str, settings: Settings): return AafRegistrationActionToken(**payload) +def _requests_sbp_bundle(registration: AafRegistrationRequest) -> bool: + if registration.bundles is None: + return False + return any(bundle.bundle_id == "sbp_workflow_execution" for bundle in registration.bundles) + + @router.post("/register") async def register_aaf( register_data: AafRegistrationRequest, @@ -285,6 +291,10 @@ async def register_aaf( validated_token = verify_registration_token(register_data.session_token, settings=settings) + if _requests_sbp_bundle(register_data) 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.") diff --git a/tests/test_aaf.py b/tests/test_aaf.py index c62736a5..5591bc2b 100644 --- a/tests/test_aaf.py +++ b/tests/test_aaf.py @@ -463,7 +463,53 @@ def test_register_aaf_returns_registration_error_when_auth0_update_fails( assert response.status_code == HTTPStatus.BAD_REQUEST assert response.json() == { - "message": "AAF registration failed: Failed to update user auth0|123: {'message': 'bad request'}", + "message": "AAF registration failed - couldn't update app_metadata: Failed to update user auth0|123: {'message': 'bad request'}", "field_errors": [], } update_user.assert_called_once() + + +def test_register_aaf_sbp_disabled_rejects_before_creating_user( + test_client, + mock_settings, + 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.aaf.verify_action_token", return_value=action_token_payload) + mocker.patch("routers.aaf.validate_recaptcha", return_value=True) + is_aaf_email = mocker.patch("routers.aaf.is_aaf_email", return_value=True) + institution_check = mocker.patch("register.utils.is_australian_research_institution_email") + update_user = mocker.patch("routers.aaf.Auth0Client.update_user") + + response = test_client.post( + "/aaf/register", + 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() + update_user.assert_not_called() From ce19704f77aad05b8fc4fbc7b6fd263fdaeb9ded Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 16:11:39 +1000 Subject: [PATCH 14/19] test: unit test for validating token from Auth0 --- tests/test_aaf.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/tests/test_aaf.py b/tests/test_aaf.py index 5591bc2b..5b85ea80 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 @@ -7,7 +8,7 @@ from fastapi import HTTPException from auth0.client import Auth0Client -from routers.aaf import link_aaf_account, mark_user_aaf_only +from routers.aaf import link_aaf_account, mark_user_aaf_only, 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 +160,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 +224,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" From 967222d221b4456df68f482d6b20943509137c2a Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Tue, 15 Sep 2026 16:24:44 +1000 Subject: [PATCH 15/19] fix: make name values optional in registration token --- schemas/auth0.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/schemas/auth0.py b/schemas/auth0.py index 8bacfddc..638c9dff 100644 --- a/schemas/auth0.py +++ b/schemas/auth0.py @@ -38,6 +38,6 @@ class AafRegistrationActionToken(Auth0ActionToken): """ Extends Auth0ActionToken with extra fields used during AAF registration. """ - given_name: str | None - family_name: str | None - name: str | None + given_name: str | None = None + family_name: str | None = None + name: str | None = None From 025ca604216d68b8493e60668c2e269316f713f5 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 16 Sep 2026 09:31:35 +1000 Subject: [PATCH 16/19] test: set email_verified by default when generating Auth0UserData --- tests/datagen.py | 1 + 1 file changed, 1 insertion(+) 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]): From 6cbbb0aac6742f2ec9a6338816d928a223f23649 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 16 Sep 2026 09:58:06 +1000 Subject: [PATCH 17/19] refactor: move AAF registration route to existing register router --- routers/aaf.py | 148 +------------------------------ routers/biocommons_register.py | 156 +++++++++++++++++++++++++++++++-- 2 files changed, 150 insertions(+), 154 deletions(-) diff --git a/routers/aaf.py b/routers/aaf.py index 9319fef5..231355a8 100644 --- a/routers/aaf.py +++ b/routers/aaf.py @@ -6,36 +6,23 @@ import httpx from fastapi import APIRouter, Depends, HTTPException -from httpx2 import HTTPStatusError from pydantic import BaseModel from sqlmodel import Session -from starlette import status -from starlette.responses import RedirectResponse, Response +from starlette.responses import RedirectResponse -from auth.validator import create_action_token, verify_action_token +from auth.validator import create_action_token from auth0.client import Auth0Client, UpdateUserData, get_auth0_client from config import Settings, get_settings from db.models import BiocommonsUser from db.setup import get_db_session from dependencies.auth import require_action_token -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 schemas.auth0 import AafRegistrationActionToken, Auth0ActionToken +from schemas.auth0 import Auth0ActionToken from schemas.biocommons import ( Auth0Identity, Auth0UserData, BiocommonsAppMetadataUpdate, BiocommonsUserAccountType, ) -from schemas.biocommons_register import AafRegistrationRequest -from schemas.responses import RegistrationErrorResponse -from services.institutions import is_aaf_email router = APIRouter( prefix="/aaf", tags=["aaf"] @@ -223,132 +210,3 @@ def check_aaf_account_link( action_token=token, settings=settings, ) - - -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) - - -def _requests_sbp_bundle(registration: AafRegistrationRequest) -> bool: - if registration.bundles is None: - return False - return any(bundle.bundle_id == "sbp_workflow_execution" for bundle in registration.bundles) - - -@router.post("/register") -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) 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)) - 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/routers/biocommons_register.py b/routers/biocommons_register.py index ebf50b9e..80c7b5c0 100644 --- a/routers/biocommons_register.py +++ b/routers/biocommons_register.py @@ -1,11 +1,15 @@ 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 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 from db.setup import get_db_session @@ -19,13 +23,24 @@ ) 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.institutions import is_aaf_email logger = logging.getLogger("uvicorn.error") @@ -56,10 +71,10 @@ def create_user_in_db(user_data: Auth0UserData, return db_user -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", @@ -85,7 +100,7 @@ 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.") @@ -170,3 +185,126 @@ 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)) + 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") From fccc5149e134d7e35e06571118e87b57b8095bcf Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 16 Sep 2026 09:58:45 +1000 Subject: [PATCH 18/19] test: unit tests of AAF register --- tests/test_aaf.py | 31 +++---- tests/test_biocommons_register.py | 146 +++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 17 deletions(-) diff --git a/tests/test_aaf.py b/tests/test_aaf.py index 5b85ea80..7f099fe7 100644 --- a/tests/test_aaf.py +++ b/tests/test_aaf.py @@ -8,7 +8,8 @@ from fastapi import HTTPException from auth0.client import Auth0Client -from routers.aaf import link_aaf_account, mark_user_aaf_only, verify_registration_token +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 @@ -484,6 +485,7 @@ def test_check_link_already_linked_is_idempotent(test_client, test_db_session, p 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() @@ -500,16 +502,13 @@ def test_register_aaf_returns_registration_error_when_auth0_update_fails( "name": "New User", } ) - mocker.patch("routers.aaf.verify_action_token", return_value=action_token_payload) - mocker.patch("routers.aaf.validate_recaptcha", return_value=True) - mocker.patch("routers.aaf.is_aaf_email", return_value=True) - update_user = mocker.patch( - "routers.aaf.Auth0Client.update_user", - side_effect=ValueError("Failed to update user auth0|123: {'message': 'bad request'}"), - ) + 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( - "/aaf/register", + "/biocommons/register-aaf", json={ "session_token": "valid_token", "username": "new_aaf_user", @@ -522,12 +521,13 @@ def test_register_aaf_returns_registration_error_when_auth0_update_fails( "message": "AAF registration failed - couldn't update app_metadata: Failed to update user auth0|123: {'message': 'bad request'}", "field_errors": [], } - update_user.assert_called_once() + 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 @@ -545,14 +545,13 @@ def test_register_aaf_sbp_disabled_rejects_before_creating_user( "name": "SBP Disabled", } ) - mocker.patch("routers.aaf.verify_action_token", return_value=action_token_payload) - mocker.patch("routers.aaf.validate_recaptcha", return_value=True) - is_aaf_email = mocker.patch("routers.aaf.is_aaf_email", return_value=True) + 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") - update_user = mocker.patch("routers.aaf.Auth0Client.update_user") response = test_client.post( - "/aaf/register", + "/biocommons/register-aaf", json={ "session_token": "valid_token", "username": "sbp_disabled_user", @@ -568,4 +567,4 @@ def test_register_aaf_sbp_disabled_rejects_before_creating_user( } is_aaf_email.assert_not_called() institution_check.assert_not_called() - update_user.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 302c0e3d..eabe4063 100644 --- a/tests/test_biocommons_register.py +++ b/tests/test_biocommons_register.py @@ -11,12 +11,13 @@ from db.models import BiocommonsUser, BiocommonsUserHistory, EmailNotification from db.types import ApprovalStatusEnum, EmailStatusEnum, GroupEnum, PlatformEnum from routers.biocommons_register import create_user_in_db -from schemas.biocommons import BiocommonsRegisterData +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( @@ -354,6 +367,137 @@ 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 + assert update_call.kwargs["update_data"].app_metadata.username == username + + 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" + + 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, From be9eb71a85b7de80f298eb7cac7bf18f0f619adb Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 16 Sep 2026 10:11:41 +1000 Subject: [PATCH 19/19] fix: set aaf_only, account_type in app_metadata --- routers/biocommons_register.py | 8 +++++++- tests/test_biocommons_register.py | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/routers/biocommons_register.py b/routers/biocommons_register.py index 80c7b5c0..8c2ab6b3 100644 --- a/routers/biocommons_register.py +++ b/routers/biocommons_register.py @@ -267,7 +267,13 @@ async def register_aaf( try: logger.info("Setting username in app_metadata") - update_data = UpdateUserData(app_metadata=BiocommonsAppMetadataUpdate(username=register_data.username)) + 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: diff --git a/tests/test_biocommons_register.py b/tests/test_biocommons_register.py index eabe4063..bf3f142f 100644 --- a/tests/test_biocommons_register.py +++ b/tests/test_biocommons_register.py @@ -413,7 +413,10 @@ def test_register_aaf_endpoint_success_no_bundles( 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 - assert update_call.kwargs["update_data"].app_metadata.username == username + 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 @@ -476,6 +479,11 @@ def test_register_aaf_endpoint_success_with_bundles( 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