Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a3be1e9
feat: add login proxy URL to settings - used to check AAF emails
marius-mather Sep 15, 2026
1564feb
test: add proxy URL to mock settings for testing
marius-mather Sep 15, 2026
cff28cc
feat: function to check if email is AAF
marius-mather Sep 15, 2026
eee8033
test: unit test of is_aaf_email
marius-mather Sep 15, 2026
f707479
refactor: move common registration logic to a shared module
marius-mather Sep 15, 2026
696b3bf
refactor: use shared utils in existing register function
marius-mather Sep 15, 2026
2cae0bd
feat: schemas for AAF registration/metadata
marius-mather Sep 15, 2026
52b354e
feat: initial implementation of AAF register endpoint
marius-mather Sep 15, 2026
ec783f4
test: rework tests of sbp domain check
marius-mather Sep 15, 2026
acd6793
refactor: move platform/bundle creation into shared utils
marius-mather Sep 15, 2026
9984b5b
fix: catch error when updating Auth0 metadata
marius-mather Sep 15, 2026
3053130
refactor: use bundle/platform utils in AAF register function
marius-mather Sep 15, 2026
54ea09f
fix: block requests for SBP if not enabled
marius-mather Sep 15, 2026
ce19704
test: unit test for validating token from Auth0
marius-mather Sep 15, 2026
967222d
fix: make name values optional in registration token
marius-mather Sep 15, 2026
025ca60
test: set email_verified by default when generating Auth0UserData
marius-mather Sep 15, 2026
6cbbb0a
refactor: move AAF registration route to existing register router
marius-mather Sep 15, 2026
fccc514
test: unit tests of AAF register
marius-mather Sep 15, 2026
be9eb71
fix: set aaf_only, account_type in app_metadata
marius-mather Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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():
Expand Down
239 changes: 239 additions & 0 deletions register/utils.py
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
marius-mather marked this conversation as resolved.
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,
)
Loading