Skip to content

Commit ca927c2

Browse files
feat: add platform access roles (AAI-467) (#112)
* feat: add role_name to platform - role that grants platform access in Auth0 * fix: make sure name is unique on Auth0Role * test: update tests to include role_name for platform * feat: add schemas for Auth0 role names * feat: methods to create platform from Åuth0 role * feat: sync task to populate platforms in the DB * fix: add populating platforms to sync job * feat: add auth0 role when adding (approved) platform membership * feat: add role when creating user record in registration * test: update tests to account for platform role * fix: include group id in 404 message * refactor: create a class for defining bundles * fix: make sure we pass string IDs when getting database groups * test: update biocommons registration tests after refactor * fix: fix BPA registration to include Auth0 client when needed * test: update test of BPA registration * fix: add a default admin role when populating platforms * fix: make platform role_name nullable: difficult to populate if it has to be there from the start * fix: add migration for platform role name * fix: include auth0_client when creating galaxy membership * test: update tests of Galaxy registration * fix: update SBP registration * test: update tests of SBP registration * feat: add an endpoint to update platform admin roles * test: test setting platform admin roles * chore: style fix Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: need to provide an actual role when defining default platform admin role --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent b971da7 commit ca927c2

17 files changed

+423
-99
lines changed

db/models.py

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import uuid
22
from datetime import datetime, timezone
3+
from logging import getLogger
34
from typing import Optional, Self
45

56
from pydantic import AwareDatetime
@@ -17,9 +18,12 @@
1718
PlatformEnum,
1819
PlatformMembershipData,
1920
)
21+
from schemas.auth0 import get_platform_id_from_role_name
2022
from schemas.tokens import AccessTokenPayload
2123
from schemas.user import SessionUser
2224

25+
logger = getLogger(__name__)
26+
2327

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

127+
def add_role(self, role_name: str, auth0_client: Auth0Client, session: Session) -> None:
128+
"""
129+
Add a role to the user in Auth0. The role must already exist in Auth0 and the DB.
130+
"""
131+
role = Auth0Role.get_by_name(role_name, session)
132+
if role is None:
133+
raise ValueError(f"Role {role_name} not found in DB")
134+
resp = auth0_client.add_roles_to_user(user_id=self.id, role_id=role.id)
135+
resp.raise_for_status()
136+
123137
def add_platform_membership(
124-
self, platform: PlatformEnum, db_session: Session, auto_approve: bool = False
138+
self, platform: PlatformEnum, db_session: Session, auth0_client: Auth0Client, auto_approve: bool = False
125139
) -> "PlatformMembership":
140+
"""
141+
Create a platform membership for this user. If auto_approve is True,
142+
add the Auth0 role for the platform to the user's roles
143+
"""
144+
db_platform = Platform.get_by_id(platform, db_session)
145+
if auto_approve:
146+
logger.info(f"Adding role {db_platform.role_name} to user {self.id}")
147+
self.add_role(role_name=db_platform.role_name, auth0_client=auth0_client, session=db_session)
126148
membership = PlatformMembership(
127149
platform_id=platform,
128150
user=self,
@@ -181,13 +203,43 @@ class PlatformRoleLink(SoftDeleteModel, table=True):
181203

182204
class Platform(SoftDeleteModel, table=True):
183205
id: PlatformEnum = Field(primary_key=True, unique=True, sa_type=DbEnum(PlatformEnum, name="PlatformEnum"))
206+
# Role name in Auth0 for basic access to the platform
207+
role_name: str | None = Field(foreign_key="auth0role.name", nullable=True)
208+
platform_role: "Auth0Role" = Relationship(back_populates="platform")
184209
# Human-readable name for the platform
185210
name: str = Field(unique=True)
186211
admin_roles: list["Auth0Role"] = Relationship(
187212
back_populates="admin_platforms", link_model=PlatformRoleLink,
188213
)
189214
members: list["PlatformMembership"] = Relationship(back_populates="platform")
190215

216+
@classmethod
217+
def create_from_auth0_role(cls, role: "Auth0Role", session: Session, commit: bool = True) -> Self:
218+
platform_id = get_platform_id_from_role_name(role.name)
219+
default_admin_role = Auth0Role.get_by_name(f"biocommons/role/{platform_id}/admin", session=session)
220+
if default_admin_role is None:
221+
raise ValueError(f"Default admin role for platform {platform_id} not found in DB. ")
222+
platform = cls(
223+
id=platform_id,
224+
role_name=role.name,
225+
name=role.description,
226+
admin_roles=[default_admin_role],
227+
)
228+
session.add(platform)
229+
if commit:
230+
session.commit()
231+
session.flush()
232+
return platform
233+
234+
def update_from_auth0_role(self, role: "Auth0Role", session: Session, commit: bool = True) -> Self:
235+
self.role_name = role.name
236+
self.name = role.description
237+
session.add(self)
238+
if commit:
239+
session.commit()
240+
session.flush()
241+
return self
242+
191243
@classmethod
192244
def get_by_id(cls, platform_id: PlatformEnum, session: Session) -> Self | None:
193245
return session.get(cls, platform_id)
@@ -237,7 +289,6 @@ def user_is_admin(self, user: SessionUser) -> bool:
237289
return True
238290
return False
239291

240-
241292
def delete(self, session: Session, commit: bool = False) -> "Platform":
242293
memberships = list(self.members or [])
243294
for membership in memberships:
@@ -652,15 +703,20 @@ class GroupRoleLink(SoftDeleteModel, table=True):
652703

653704
class Auth0Role(SoftDeleteModel, table=True):
654705
id: str = Field(primary_key=True, unique=True)
655-
name: str
706+
name: str = Field(unique=True)
656707
description: str = Field(default="")
708+
platform: Platform | None = Relationship(back_populates="platform_role")
657709
admin_groups: list["BiocommonsGroup"] = Relationship(
658710
back_populates="admin_roles", link_model=GroupRoleLink
659711
)
660712
admin_platforms: list["Platform"] = Relationship(
661713
back_populates="admin_roles", link_model=PlatformRoleLink
662714
)
663715

716+
@classmethod
717+
def get_by_id(cls, role_id: str, session: Session) -> Self | None:
718+
return session.get(Auth0Role, role_id)
719+
664720
@classmethod
665721
def get_or_create_by_id(
666722
cls, auth0_id: str, session: Session, auth0_client: Auth0Client
@@ -724,7 +780,7 @@ def get_by_id(cls, group_id: str, session: Session) -> Self | None:
724780
def get_by_id_or_404(cls, group_id: str, session: Session) -> Self:
725781
group = cls.get_by_id(group_id, session)
726782
if group is None:
727-
raise HTTPException(status_code=404, detail="Group not found")
783+
raise HTTPException(status_code=404, detail=f"Group {group_id} not found in database")
728784
return group
729785

730786
def delete(self, session: Session, commit: bool = False) -> "BiocommonsGroup":
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""platform_role_name
2+
3+
Revision ID: 4271e34a0adf
4+
Revises: 6c9d1e8540be
5+
Create Date: 2025-11-04 11:41:03.519358
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
import sqlmodel
13+
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = '4271e34a0adf'
17+
down_revision: Union[str, None] = '6c9d1e8540be'
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.create_unique_constraint(op.f('uq_auth0role_name'), 'auth0role', ['name'])
25+
op.add_column('platform', sa.Column('role_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
26+
op.create_foreign_key(op.f('fk_platform_role_name_auth0role'), 'platform', 'auth0role', ['role_name'], ['name'])
27+
# ### end Alembic commands ###
28+
29+
30+
def downgrade() -> None:
31+
# ### commands auto generated by Alembic - please adjust! ###
32+
op.drop_constraint(op.f('fk_platform_role_name_auth0role'), 'platform', type_='foreignkey')
33+
op.drop_column('platform', 'role_name')
34+
op.drop_constraint(op.f('uq_auth0role_name'), 'auth0role', type_='unique')
35+
# ### end Alembic commands ###

routers/biocommons_admin.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def save_platform(self, db_session: Session, commit: bool = False):
7171
db_roles.append(db_role)
7272
platform = Platform(
7373
id=self.id,
74+
role_name=f"biocommons/platform/{self.id}",
7475
name=self.name,
7576
admin_roles=db_roles,
7677
)
@@ -103,6 +104,28 @@ def create_platform(platform_data: PlatformCreateData, db_session: Annotated[Ses
103104
)
104105

105106

107+
class SetRolesData(BaseModel):
108+
role_names: list[str]
109+
110+
111+
@router.post("/platforms/{platform_id}/set-admin-roles")
112+
def set_platform_admin_roles(platform_id: PlatformEnum, data: SetRolesData, db_session: Annotated[Session, Depends(get_db_session)]):
113+
platform = Platform.get_by_id(platform_id, db_session)
114+
db_roles = []
115+
for role_name in data.role_names:
116+
role = Auth0Role.get_by_name(role_name, db_session)
117+
if role is None:
118+
raise HTTPException(
119+
status_code=HTTPStatus.BAD_REQUEST,
120+
detail=f"Role {role_name} doesn't exist in DB - create roles first"
121+
)
122+
db_roles.append(role)
123+
platform.admin_roles = db_roles
124+
db_session.add(platform)
125+
db_session.commit()
126+
return {"message": f"Admin roles for platform {platform_id} set successfully."}
127+
128+
106129
class CreateRoleData(BaseModel):
107130
name: RoleId | GroupId
108131
description: str

routers/biocommons_register.py

Lines changed: 62 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
44
from httpx import HTTPStatusError
5+
from pydantic import BaseModel
56
from sqlmodel import Session
67
from starlette.responses import JSONResponse
78

@@ -18,19 +19,62 @@
1819

1920
logger = logging.getLogger(__name__)
2021

22+
23+
class BiocommonsBundle(BaseModel):
24+
id: BundleType
25+
group_id: GroupEnum
26+
group_auto_approve: bool
27+
# Platforms that are automatically approved upon registration
28+
platforms: list[PlatformEnum]
29+
30+
def _add_group_membership(self, user: BiocommonsUser, session: Session):
31+
# Verify group exists
32+
BiocommonsGroup.get_by_id_or_404(group_id=self.group_id.value, session=session)
33+
group_membership = user.add_group_membership(
34+
group_id=self.group_id.value, db_session=session, auto_approve=self.group_auto_approve
35+
)
36+
session.add(group_membership)
37+
38+
def _add_platform_memberships(self, user: BiocommonsUser, session: Session, auth0_client: Auth0Client):
39+
for platform in self.platforms:
40+
logger.info(f"Adding platform membership for {platform.value} to user {user.id}")
41+
platform_membership = user.add_platform_membership(
42+
platform=platform, db_session=session, auth0_client=auth0_client, auto_approve=True
43+
)
44+
session.add(platform_membership)
45+
46+
def create_user_record(self, auth0_user_data: Auth0UserData, auth0_client: Auth0Client, db_session: Session):
47+
"""
48+
Create a user record for the bundle user.
49+
"""
50+
db_user = BiocommonsUser.from_auth0_data(data=auth0_user_data)
51+
db_session.add(db_user)
52+
db_session.flush()
53+
# Create group membership
54+
self._add_group_membership(user=db_user, session=db_session)
55+
# Add platform memberships based on bundle configuration
56+
self._add_platform_memberships(user=db_user, session=db_session, auth0_client=auth0_client)
57+
db_session.commit()
58+
return db_user
59+
60+
2161
# Bundle configuration mapping bundle names to their groups and included platforms
2262
# Note: Platforms listed here are auto-approved upon registration,
2363
# while group memberships require manual approval
2464
# Currently BPA Data Portal and Galaxy are auto-approved for all bundles
25-
BUNDLES: dict[BundleType, dict] = {
26-
"bpa_galaxy": {
27-
"group_id": GroupEnum.BPA_GALAXY,
28-
"platforms": [PlatformEnum.BPA_DATA_PORTAL, PlatformEnum.GALAXY],
29-
},
30-
"tsi": {
31-
"group_id": GroupEnum.TSI,
32-
"platforms": [PlatformEnum.BPA_DATA_PORTAL, PlatformEnum.GALAXY],
33-
},
65+
BUNDLES: dict[BundleType, BiocommonsBundle] = {
66+
"bpa_galaxy": BiocommonsBundle(
67+
id="bpa_galaxy",
68+
group_id=GroupEnum.BPA_GALAXY,
69+
group_auto_approve=True,
70+
platforms=[PlatformEnum.BPA_DATA_PORTAL, PlatformEnum.GALAXY],
71+
),
72+
"tsi": BiocommonsBundle(
73+
id="tsi",
74+
group_id=GroupEnum.TSI,
75+
group_auto_approve=False,
76+
platforms=[PlatformEnum.BPA_DATA_PORTAL, PlatformEnum.GALAXY],
77+
),
3478
}
3579

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

73117
# Create Auth0 user data
74118
user_data = BiocommonsRegisterData.from_biocommons_registration(registration)
119+
bundle = BUNDLES[registration.bundle]
75120

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

80125
logger.info("Adding user to DB")
81-
_create_biocommons_user_record(auth0_user_data, registration, db_session)
126+
bundle.create_user_record(
127+
auth0_user_data=auth0_user_data,
128+
auth0_client=auth0_client,
129+
db_session=db_session
130+
)
82131

83132
# Send approval email in background
84-
if settings.send_email:
85-
background_tasks.add_task(send_approval_email, registration, settings)
133+
if not bundle.group_auto_approve:
134+
if settings.send_email:
135+
background_tasks.add_task(send_approval_email, registration, settings)
86136

87137
logger.info(
88138
f"Successfully registered biocommons user: {auth0_user_data.user_id}"
@@ -103,41 +153,3 @@ async def register_biocommons_user(
103153
except Exception as e:
104154
logger.error(f"Unexpected error during registration: {e}")
105155
raise HTTPException(status_code=500, detail="Internal server error")
106-
107-
108-
def _create_biocommons_user_record(
109-
auth0_user_data: Auth0UserData,
110-
registration: BiocommonsRegistrationRequest,
111-
session: Session,
112-
) -> BiocommonsUser:
113-
"""Create a BioCommons user record in the database with group membership based on selected bundle."""
114-
db_user = BiocommonsUser.from_auth0_data(data=auth0_user_data)
115-
session.add(db_user)
116-
session.flush()
117-
118-
# Get bundle configuration
119-
bundle_config = BUNDLES[registration.bundle]
120-
group_id = bundle_config["group_id"]
121-
122-
# Verify the group exists (this will raise an error if it doesn't)
123-
db_group = BiocommonsGroup.get_by_id(group_id, session)
124-
if not db_group:
125-
raise ValueError(
126-
f"Group '{group_id.value}' not found. Groups must be pre-configured in the database."
127-
)
128-
129-
# Create group membership
130-
group_membership = db_user.add_group_membership(
131-
group_id=group_id, db_session=session, auto_approve=False
132-
)
133-
session.add(group_membership)
134-
135-
# Add platform memberships based on bundle configuration
136-
for platform in bundle_config["platforms"]:
137-
platform_membership = db_user.add_platform_membership(
138-
platform=platform, db_session=session, auto_approve=True
139-
)
140-
session.add(platform_membership)
141-
142-
session.commit()
143-
return db_user

routers/bpa_register.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async def register_bpa_user(
4646
auth0_user_data = auth0_client.create_user(user_data)
4747

4848
logger.info("Adding user to DB")
49-
_create_bpa_user_record(auth0_user_data, db_session)
49+
_create_bpa_user_record(auth0_user_data, auth0_client=auth0_client, session=db_session)
5050

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

@@ -65,11 +65,12 @@ async def register_bpa_user(
6565
)
6666

6767

68-
def _create_bpa_user_record(auth0_user_data: Auth0UserData, session: Session) -> BiocommonsUser:
68+
def _create_bpa_user_record(auth0_user_data: Auth0UserData, auth0_client: Auth0Client, session: Session) -> BiocommonsUser:
6969
db_user = BiocommonsUser.from_auth0_data(data=auth0_user_data)
7070
bpa_membership = db_user.add_platform_membership(
7171
platform=PlatformEnum.BPA_DATA_PORTAL,
7272
db_session=session,
73+
auth0_client=auth0_client,
7374
auto_approve=True
7475
)
7576
session.add(db_user)

routers/galaxy_register.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,15 @@ def register(
6868
return JSONResponse(status_code=400, content=response.model_dump(mode="json"))
6969
# Add to database and record Galaxy membership
7070
logger.info("Adding user to DB")
71-
_create_galaxy_user_record(auth0_user_data, db_session)
71+
_create_galaxy_user_record(auth0_user_data, auth0_client=auth0_client, session=db_session)
7272
return {"message": "User registered successfully", "user": auth0_user_data.model_dump(mode="json")}
7373

7474

75-
def _create_galaxy_user_record(auth0_user_data: Auth0UserData, session: Session) -> BiocommonsUser:
75+
def _create_galaxy_user_record(auth0_user_data: Auth0UserData, auth0_client: Auth0Client, session: Session) -> BiocommonsUser:
7676
db_user = BiocommonsUser.from_auth0_data(data=auth0_user_data)
7777
galaxy_membership = db_user.add_platform_membership(
7878
platform=PlatformEnum.GALAXY,
79+
auth0_client=auth0_client,
7980
db_session=session,
8081
auto_approve=True
8182
)

0 commit comments

Comments
 (0)