Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
c18ebfc
feat: add role_name to platform - role that grants platform access in…
marius-mather Oct 31, 2025
a905e6c
fix: make sure name is unique on Auth0Role
marius-mather Oct 31, 2025
711c940
test: update tests to include role_name for platform
marius-mather Oct 31, 2025
94785e9
feat: add schemas for Auth0 role names
marius-mather Nov 3, 2025
8419831
feat: methods to create platform from Åuth0 role
marius-mather Nov 3, 2025
cb87a2b
feat: sync task to populate platforms in the DB
marius-mather Nov 3, 2025
524e27c
fix: add populating platforms to sync job
marius-mather Nov 3, 2025
611b9cf
feat: add auth0 role when adding (approved) platform membership
marius-mather Nov 3, 2025
59dcad9
feat: add role when creating user record in registration
marius-mather Nov 3, 2025
792dba3
test: update tests to account for platform role
marius-mather Nov 3, 2025
d84f0ef
fix: include group id in 404 message
marius-mather Nov 3, 2025
e667be8
refactor: create a class for defining bundles
marius-mather Nov 3, 2025
f2199c9
fix: make sure we pass string IDs when getting database groups
marius-mather Nov 3, 2025
641334a
test: update biocommons registration tests after refactor
marius-mather Nov 3, 2025
1e594bd
fix: fix BPA registration to include Auth0 client when needed
marius-mather Nov 3, 2025
fb82382
test: update test of BPA registration
marius-mather Nov 3, 2025
a72b7a4
fix: add a default admin role when populating platforms
marius-mather Nov 3, 2025
3faed19
fix: make platform role_name nullable: difficult to populate if it ha…
marius-mather Nov 4, 2025
819608d
fix: add migration for platform role name
marius-mather Nov 4, 2025
ed18b84
fix: include auth0_client when creating galaxy membership
marius-mather Nov 4, 2025
1cb5cc2
test: update tests of Galaxy registration
marius-mather Nov 4, 2025
c1ae4b8
fix: update SBP registration
marius-mather Nov 4, 2025
bfa2c67
test: update tests of SBP registration
marius-mather Nov 4, 2025
77f085a
feat: add an endpoint to update platform admin roles
marius-mather Nov 4, 2025
894fd42
test: test setting platform admin roles
marius-mather Nov 4, 2025
d5baffa
chore: style fix
marius-mather Nov 4, 2025
e192427
fix: need to provide an actual role when defining default platform ad…
marius-mather Nov 4, 2025
ab7e261
refactor: remove current platform role name migration
marius-mather Nov 5, 2025
5e50829
refactor: make platform link to roles via role ID instead of name
marius-mather Nov 5, 2025
152e417
refactor: undo unique on Auth0Role.name
marius-mather Nov 5, 2025
4fba7a4
refactor: add updated migration for platform-role link
marius-mather Nov 5, 2025
3042bf4
refactor: add commit arg to saving platform membership history
marius-mather Nov 6, 2025
08f8a57
fix: use a memory jobstore for sync tasks in dev, sqlite doesn't like…
marius-mather Nov 6, 2025
3c167ce
feat: add platform membership syncing, refactor group membership sync
marius-mather Nov 6, 2025
8fdb598
feat: add platform sync to scheduled jobs
marius-mather Nov 6, 2025
fe54cd8
test: update tests after refactor
marius-mather Nov 6, 2025
c35b71b
fix: fix membership queries after refactoring - only get the current …
marius-mather Nov 6, 2025
7b1bd62
test: add test of platform membership syncing
marius-mather Nov 6, 2025
a00a992
chore: remove print statement when job scheduler is waiting
marius-mather Nov 6, 2025
df75418
chore: improved log message for platform sync
marius-mather Nov 6, 2025
c50f00a
fix: fix task ID in scheduler
marius-mather Nov 7, 2025
70d71e1
fix: use role ID when linking platform to role
marius-mather Nov 7, 2025
95af1c7
fix: remove unused variable
marius-mather Nov 7, 2025
06dc3d9
fix: use role ID when creating platform in tests
marius-mather Nov 7, 2025
517c7ac
Merge branch 'feature/platform-roles' of github.com:AustralianBioComm…
marius-mather Nov 7, 2025
6b4789f
fix: check for platform role when creating platform
marius-mather Nov 7, 2025
730b10f
test: fixes for platform creation tests
marius-mather Nov 7, 2025
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
67 changes: 63 additions & 4 deletions db/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import uuid
from datetime import datetime, timezone
from logging import getLogger
from typing import Optional, Self

from pydantic import AwareDatetime
Expand All @@ -17,9 +18,12 @@
PlatformEnum,
PlatformMembershipData,
)
from schemas.auth0 import get_platform_id_from_role_name
from schemas.tokens import AccessTokenPayload
from schemas.user import SessionUser

logger = getLogger(__name__)


class BiocommonsUser(SoftDeleteModel, table=True):
__tablename__ = "biocommons_user"
Expand Down Expand Up @@ -120,9 +124,27 @@ def update_from_auth0_data(self, data: 'schemas.biocommons.Auth0UserData') -> Se
self.email_verified = data.email_verified
return self

def add_role(self, role_name: str, auth0_client: Auth0Client, session: Session) -> None:
"""
Add a role to the user in Auth0. The role must already exist in Auth0 and the DB.
"""
role = Auth0Role.get_by_name(role_name, session)
if role is None:
raise ValueError(f"Role {role_name} not found in DB")
resp = auth0_client.add_roles_to_user(user_id=self.id, role_id=role.id)
resp.raise_for_status()

def add_platform_membership(
self, platform: PlatformEnum, db_session: Session, auto_approve: bool = False
self, platform: PlatformEnum, db_session: Session, auth0_client: Auth0Client, auto_approve: bool = False
) -> "PlatformMembership":
"""
Create a platform membership for this user. If auto_approve is True,
add the Auth0 role for the platform to the user's roles
"""
db_platform = Platform.get_by_id(platform, db_session)
if auto_approve:
logger.info(f"Adding role {db_platform.platform_role.name} to user {self.id}")
self.add_role(role_name=db_platform.platform_role.name, auth0_client=auth0_client, session=db_session)
membership = PlatformMembership(
platform_id=platform,
user=self,
Expand Down Expand Up @@ -181,13 +203,44 @@ class PlatformRoleLink(SoftDeleteModel, table=True):

class Platform(SoftDeleteModel, table=True):
id: PlatformEnum = Field(primary_key=True, unique=True, sa_type=DbEnum(PlatformEnum, name="PlatformEnum"))
# Role name in Auth0 for basic access to the platform
role_id: str | None = Field(foreign_key="auth0role.id", ondelete="SET NULL", nullable=True)
platform_role: "Auth0Role" = Relationship(back_populates="platform")
# Human-readable name for the platform
name: str = Field(unique=True)
admin_roles: list["Auth0Role"] = Relationship(
back_populates="admin_platforms", link_model=PlatformRoleLink,
)
members: list["PlatformMembership"] = Relationship(back_populates="platform")

@classmethod
def create_from_auth0_role(cls, role: "Auth0Role", session: Session, commit: bool = True) -> Self:
platform_id = get_platform_id_from_role_name(role.name)
default_admin_role = Auth0Role.get_by_name(f"biocommons/role/{platform_id}/admin", session=session)
if default_admin_role is None:
raise ValueError(f"Default admin role for platform {platform_id} not found in DB. ")
platform = cls(
id=platform_id,
role_id=role.id,
name=role.description,
admin_roles=[default_admin_role],
)
session.add(platform)
if commit:
session.commit()
session.flush()
return platform

def update_from_auth0_role(self, role: "Auth0Role", session: Session, commit: bool = True) -> Self:
# May need to update the ID if a role has been deleted and recreated
self.role_id = role.id
self.name = role.description
session.add(self)
if commit:
session.commit()
session.flush()
return self

@classmethod
def get_by_id(cls, platform_id: PlatformEnum, session: Session) -> Self | None:
return session.get(cls, platform_id)
Expand Down Expand Up @@ -237,7 +290,6 @@ def user_is_admin(self, user: SessionUser) -> bool:
return True
return False


def delete(self, session: Session, commit: bool = False) -> "Platform":
memberships = list(self.members or [])
for membership in memberships:
Expand Down Expand Up @@ -330,7 +382,7 @@ def delete(self, session: Session, commit: bool = False) -> "PlatformMembership"
session.expunge(self)
return self

def save_history(self, session: Session) -> "PlatformMembershipHistory":
def save_history(self, session: Session, commit: bool = False) -> "PlatformMembershipHistory":
# Make sure this object is in the session before accessing relationships
if self not in session:
session.add(self)
Expand All @@ -345,6 +397,8 @@ def save_history(self, session: Session) -> "PlatformMembershipHistory":
reason=self.revocation_reason,
)
session.add(history)
if commit:
session.commit()
return history

def get_data(self) -> PlatformMembershipData:
Expand Down Expand Up @@ -654,13 +708,18 @@ class Auth0Role(SoftDeleteModel, table=True):
id: str = Field(primary_key=True, unique=True)
name: str
description: str = Field(default="")
platform: Platform | None = Relationship(back_populates="platform_role")
admin_groups: list["BiocommonsGroup"] = Relationship(
back_populates="admin_roles", link_model=GroupRoleLink
)
admin_platforms: list["Platform"] = Relationship(
back_populates="admin_roles", link_model=PlatformRoleLink
)

@classmethod
def get_by_id(cls, role_id: str, session: Session) -> Self | None:
return session.get(Auth0Role, role_id)

@classmethod
def get_or_create_by_id(
cls, auth0_id: str, session: Session, auth0_client: Auth0Client
Expand Down Expand Up @@ -724,7 +783,7 @@ def get_by_id(cls, group_id: str, session: Session) -> Self | None:
def get_by_id_or_404(cls, group_id: str, session: Session) -> Self:
group = cls.get_by_id(group_id, session)
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
raise HTTPException(status_code=404, detail=f"Group {group_id} not found in database")
return group

def delete(self, session: Session, commit: bool = False) -> "BiocommonsGroup":
Expand Down
33 changes: 33 additions & 0 deletions migrations/versions/4594b458279c_platform_roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""platform_roles
Revision ID: 4594b458279c
Revises: 6c9d1e8540be
Create Date: 2025-11-05 14:05:57.399804
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


# revision identifiers, used by Alembic.
revision: str = '4594b458279c'
down_revision: Union[str, None] = '6c9d1e8540be'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('platform', sa.Column('role_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.create_foreign_key(op.f('fk_platform_role_id_auth0role'), 'platform', 'auth0role', ['role_id'], ['id'], ondelete='SET NULL')
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('fk_platform_role_id_auth0role'), 'platform', type_='foreignkey')
op.drop_column('platform', 'role_id')
# ### end Alembic commands ###
26 changes: 26 additions & 0 deletions routers/biocommons_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,12 @@ def save_platform(self, db_session: Session, commit: bool = False):
detail=f"Role {role} doesn't exist in DB - create roles first"
)
db_roles.append(db_role)
platform_role = Auth0Role.get_by_name(f"biocommons/platform/{self.id.value}", db_session)
if platform_role is None:
raise HTTPException(status_code=404, detail=f"Role biocommons/platform/{self.id.value} not found")
platform = Platform(
id=self.id,
role_id=platform_role.id,
name=self.name,
admin_roles=db_roles,
)
Expand Down Expand Up @@ -103,6 +107,28 @@ def create_platform(platform_data: PlatformCreateData, db_session: Annotated[Ses
)


class SetRolesData(BaseModel):
role_names: list[str]


@router.post("/platforms/{platform_id}/set-admin-roles")
def set_platform_admin_roles(platform_id: PlatformEnum, data: SetRolesData, db_session: Annotated[Session, Depends(get_db_session)]):
platform = Platform.get_by_id(platform_id, db_session)
db_roles = []
for role_name in data.role_names:
role = Auth0Role.get_by_name(role_name, db_session)
if role is None:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Role {role_name} doesn't exist in DB - create roles first"
)
db_roles.append(role)
platform.admin_roles = db_roles
db_session.add(platform)
db_session.commit()
return {"message": f"Admin roles for platform {platform_id} set successfully."}


class CreateRoleData(BaseModel):
name: RoleId | GroupId
description: str
Expand Down
112 changes: 62 additions & 50 deletions routers/biocommons_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from httpx import HTTPStatusError
from pydantic import BaseModel
from sqlmodel import Session
from starlette.responses import JSONResponse

Expand All @@ -18,19 +19,62 @@

logger = logging.getLogger(__name__)


class BiocommonsBundle(BaseModel):
id: BundleType
group_id: GroupEnum
group_auto_approve: bool
# Platforms that are automatically approved upon registration
platforms: list[PlatformEnum]

def _add_group_membership(self, user: BiocommonsUser, session: Session):
# Verify group exists
BiocommonsGroup.get_by_id_or_404(group_id=self.group_id.value, session=session)
group_membership = user.add_group_membership(
group_id=self.group_id.value, db_session=session, auto_approve=self.group_auto_approve
)
session.add(group_membership)

def _add_platform_memberships(self, user: BiocommonsUser, session: Session, auth0_client: Auth0Client):
for platform in self.platforms:
logger.info(f"Adding platform membership for {platform.value} to user {user.id}")
platform_membership = user.add_platform_membership(
platform=platform, db_session=session, auth0_client=auth0_client, auto_approve=True
)
session.add(platform_membership)

def create_user_record(self, auth0_user_data: Auth0UserData, auth0_client: Auth0Client, db_session: Session):
"""
Create a user record for the bundle user.
"""
db_user = BiocommonsUser.from_auth0_data(data=auth0_user_data)
db_session.add(db_user)
db_session.flush()
# Create group membership
self._add_group_membership(user=db_user, session=db_session)
# Add platform memberships based on bundle configuration
self._add_platform_memberships(user=db_user, session=db_session, auth0_client=auth0_client)
db_session.commit()
return db_user


# Bundle configuration mapping bundle names to their groups and included platforms
# Note: Platforms listed here are auto-approved upon registration,
# while group memberships require manual approval
# Currently BPA Data Portal and Galaxy are auto-approved for all bundles
BUNDLES: dict[BundleType, dict] = {
"bpa_galaxy": {
"group_id": GroupEnum.BPA_GALAXY,
"platforms": [PlatformEnum.BPA_DATA_PORTAL, PlatformEnum.GALAXY],
},
"tsi": {
"group_id": GroupEnum.TSI,
"platforms": [PlatformEnum.BPA_DATA_PORTAL, PlatformEnum.GALAXY],
},
BUNDLES: dict[BundleType, BiocommonsBundle] = {
"bpa_galaxy": BiocommonsBundle(
id="bpa_galaxy",
group_id=GroupEnum.BPA_GALAXY,
group_auto_approve=True,
platforms=[PlatformEnum.BPA_DATA_PORTAL, PlatformEnum.GALAXY],
),
"tsi": BiocommonsBundle(
id="tsi",
group_id=GroupEnum.TSI,
group_auto_approve=False,
platforms=[PlatformEnum.BPA_DATA_PORTAL, PlatformEnum.GALAXY],
),
}

router = APIRouter(prefix="/biocommons", tags=["biocommons", "registration"], route_class=RegistrationRoute)
Expand Down Expand Up @@ -72,17 +116,23 @@ async def register_biocommons_user(

# Create Auth0 user data
user_data = BiocommonsRegisterData.from_biocommons_registration(registration)
bundle = BUNDLES[registration.bundle]

try:
logger.info("Registering user with Auth0")
auth0_user_data = auth0_client.create_user(user_data)

logger.info("Adding user to DB")
_create_biocommons_user_record(auth0_user_data, registration, db_session)
bundle.create_user_record(
auth0_user_data=auth0_user_data,
auth0_client=auth0_client,
db_session=db_session
)

# Send approval email in background
if settings.send_email:
background_tasks.add_task(send_approval_email, registration, settings)
if not bundle.group_auto_approve:
if settings.send_email:
background_tasks.add_task(send_approval_email, registration, settings)

logger.info(
f"Successfully registered biocommons user: {auth0_user_data.user_id}"
Expand All @@ -103,41 +153,3 @@ 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_biocommons_user_record(
auth0_user_data: Auth0UserData,
registration: BiocommonsRegistrationRequest,
session: Session,
) -> BiocommonsUser:
"""Create a BioCommons user record in the database with group membership based on selected bundle."""
db_user = BiocommonsUser.from_auth0_data(data=auth0_user_data)
session.add(db_user)
session.flush()

# Get bundle configuration
bundle_config = BUNDLES[registration.bundle]
group_id = bundle_config["group_id"]

# Verify the group exists (this will raise an error if it doesn't)
db_group = BiocommonsGroup.get_by_id(group_id, session)
if not db_group:
raise ValueError(
f"Group '{group_id.value}' not found. Groups must be pre-configured in the database."
)

# Create group membership
group_membership = db_user.add_group_membership(
group_id=group_id, db_session=session, auto_approve=False
)
session.add(group_membership)

# Add platform memberships based on bundle configuration
for platform in bundle_config["platforms"]:
platform_membership = db_user.add_platform_membership(
platform=platform, db_session=session, auto_approve=True
)
session.add(platform_membership)

session.commit()
return db_user
5 changes: 3 additions & 2 deletions routers/bpa_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async def register_bpa_user(
auth0_user_data = auth0_client.create_user(user_data)

logger.info("Adding user to DB")
_create_bpa_user_record(auth0_user_data, db_session)
_create_bpa_user_record(auth0_user_data, auth0_client=auth0_client, session=db_session)

return {"message": "User registered successfully", "user": auth0_user_data.model_dump(mode="json")}

Expand All @@ -65,11 +65,12 @@ async def register_bpa_user(
)


def _create_bpa_user_record(auth0_user_data: Auth0UserData, session: Session) -> BiocommonsUser:
def _create_bpa_user_record(auth0_user_data: Auth0UserData, auth0_client: Auth0Client, session: Session) -> BiocommonsUser:
db_user = BiocommonsUser.from_auth0_data(data=auth0_user_data)
bpa_membership = db_user.add_platform_membership(
platform=PlatformEnum.BPA_DATA_PORTAL,
db_session=session,
auth0_client=auth0_client,
auto_approve=True
)
session.add(db_user)
Expand Down
Loading