Skip to content

Commit 478fed8

Browse files
authored
feat: add rejected approval status (AAI-526) (#141)
* feat: add rejected status to groupmembership * fix: autolink group admin roles based on name * fix: version inconsistency in migration script
1 parent 040b783 commit 478fed8

File tree

10 files changed

+493
-27
lines changed

10 files changed

+493
-27
lines changed

db/models.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,10 @@ class GroupMembership(SoftDeleteModel, table=True):
535535
default=None,
536536
sa_column=Column(String(1024), nullable=True)
537537
)
538+
rejection_reason: str | None = Field(
539+
default=None,
540+
sa_column=Column(String(255), nullable=True),
541+
)
538542

539543
@classmethod
540544
def get_by_user_id(
@@ -620,13 +624,14 @@ def save_history(
620624
session.add(self)
621625
session.flush()
622626

627+
history_reason = self.rejection_reason if self.approval_status == ApprovalStatusEnum.REJECTED else self.revocation_reason
623628
history = GroupMembershipHistory(
624629
group_id=self.group_id,
625630
user_id=self.user_id,
626631
approval_status=self.approval_status,
627632
updated_at=self.updated_at,
628633
updated_by=self.updated_by,
629-
reason=self.revocation_reason,
634+
reason=history_reason,
630635
)
631636
session.add(history)
632637
if commit:
@@ -673,6 +678,23 @@ def revoke(
673678
self.save(session=session, commit=commit)
674679
return role_revoked
675680

681+
def reject(
682+
self,
683+
*,
684+
reason: str,
685+
updated_by: Optional["BiocommonsUser"],
686+
session: Session,
687+
commit: bool = True,
688+
) -> None:
689+
if self.approval_status != ApprovalStatusEnum.PENDING:
690+
raise ValueError("Can only reject pending group memberships.")
691+
self.approval_status = ApprovalStatusEnum.REJECTED
692+
self.rejection_reason = reason
693+
self.revocation_reason = None
694+
self.updated_at = datetime.now(timezone.utc)
695+
self.updated_by = updated_by
696+
self.save(session=session, commit=commit)
697+
676698
def get_data(self) -> GroupMembershipData:
677699
"""
678700
Get a data model for this membership, suitable for returning to the frontend.
@@ -689,6 +711,7 @@ def get_data(self) -> GroupMembershipData:
689711
approval_status=self.approval_status,
690712
updated_by=updated_by,
691713
revocation_reason=self.revocation_reason,
714+
rejection_reason=self.rejection_reason,
692715
)
693716

694717

@@ -808,6 +831,10 @@ def get_by_name(cls, name: str, session: Session) -> Self | None:
808831
select(Auth0Role).where(Auth0Role.name == name)
809832
).one_or_none()
810833

834+
@classmethod
835+
def get_all(cls, session: Session) -> list["Auth0Role"]:
836+
return session.exec(select(Auth0Role)).all()
837+
811838

812839
class BiocommonsGroup(SoftDeleteModel, table=True):
813840
# Name of the group / role name in Auth0, e.g. biocommons/group/tsi

db/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class ApprovalStatusEnum(str, Enum):
1414
APPROVED = "approved"
1515
PENDING = "pending"
1616
REVOKED = "revoked"
17+
REJECTED = "rejected"
1718

1819

1920
class EmailStatusEnum(str, Enum):
@@ -49,6 +50,7 @@ class GroupMembershipData(BaseModel):
4950
approval_status: ApprovalStatusEnum
5051
updated_by: str
5152
revocation_reason: str | None = None
53+
rejection_reason: str | None = None
5254

5355

5456
class GroupEnum(str, Enum):
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""group-membership-rejection
2+
3+
Revision ID: c4c7a8e9b2d3
4+
Revises: d6a5578732bc
5+
Create Date: 2025-12-02 12:00:00.000000
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "c4c7a8e9b2d3"
16+
down_revision: Union[str, Sequence[str], None] = "d6a5578732bc"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
bind = op.get_bind()
23+
dialect_name = bind.dialect.name
24+
25+
if dialect_name != "sqlite":
26+
op.execute('ALTER TYPE "ApprovalStatusEnum" ADD VALUE \'REJECTED\'')
27+
28+
op.add_column(
29+
"groupmembership",
30+
sa.Column("rejection_reason", sa.String(length=255), nullable=True),
31+
)
32+
33+
34+
def downgrade() -> None:
35+
op.drop_column("groupmembership", "rejection_reason")

routers/admin.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from sqlalchemy import func, or_
1111
from sqlmodel import Session, select
1212
from sqlmodel.sql._expression_select_cls import SelectOfScalar
13+
from starlette import status
1314

1415
from auth.user_permissions import (
1516
get_db_user,
@@ -134,6 +135,16 @@ def strip_reason(cls, value: str | None) -> str | None:
134135
return stripped or None
135136

136137

138+
class RejectServiceRequest(BaseModel):
139+
reason: Annotated[str, Field(min_length=1, max_length=255)]
140+
141+
@field_validator("reason")
142+
@classmethod
143+
def strip_reason(cls, value: str) -> str:
144+
stripped = value.strip()
145+
if not stripped:
146+
raise ValueError("Reason must not be empty")
147+
return stripped
137148

138149
def _membership_response() -> dict[str, object]:
139150
return {"status": "ok", "updated": True}
@@ -208,9 +219,15 @@ def _approve_group_membership(
208219
group_id=group.group_id,
209220
session=db_session,
210221
)
222+
if membership.approval_status not in {ApprovalStatusEnum.PENDING, ApprovalStatusEnum.REVOKED}:
223+
raise HTTPException(
224+
status_code=status.HTTP_400_BAD_REQUEST,
225+
detail="Only pending or revoked group memberships can be approved.",
226+
)
211227
status_changed = membership.approval_status != ApprovalStatusEnum.APPROVED
212228
membership.approval_status = ApprovalStatusEnum.APPROVED
213229
membership.revocation_reason = None
230+
membership.rejection_reason = None
214231
membership.updated_at = datetime.now(timezone.utc)
215232
membership.updated_by = admin_record
216233
membership.grant_auth0_role(auth0_client=client)
@@ -817,6 +834,39 @@ def approve_group_membership(user_id: Annotated[str, UserIdParam],
817834
return _membership_response()
818835

819836

837+
@router.post("/users/{user_id}/groups/{group_id:path}/reject",
838+
dependencies=[Depends(require_admin_permission_for_group)])
839+
def reject_group_membership(user_id: Annotated[str, UserIdParam],
840+
group_id: Annotated[str, ServiceIdParam],
841+
payload: RejectServiceRequest,
842+
admin_record: Annotated[BiocommonsUser, Depends(get_db_user)],
843+
db_session: Annotated[Session, Depends(get_db_session)]):
844+
membership = GroupMembership.get_by_user_id_and_group_id_or_404(
845+
user_id=user_id,
846+
group_id=group_id,
847+
session=db_session,
848+
)
849+
if membership.approval_status != ApprovalStatusEnum.PENDING:
850+
raise HTTPException(
851+
status_code=status.HTTP_400_BAD_REQUEST,
852+
detail="Only pending group requests can be rejected.",
853+
)
854+
membership.reject(
855+
reason=payload.reason,
856+
updated_by=admin_record,
857+
session=db_session,
858+
commit=False,
859+
)
860+
db_session.commit()
861+
logger.info(
862+
"Rejected group %s for user %s: %s",
863+
group_id,
864+
user_id,
865+
payload.reason,
866+
)
867+
return _membership_response()
868+
869+
820870
@router.post("/users/{user_id}/groups/{group_id:path}/revoke",
821871
dependencies=[Depends(require_admin_permission_for_group)])
822872
def revoke_group_membership(user_id: Annotated[str, UserIdParam],

routers/biocommons_groups.py

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from datetime import datetime, timezone
23
from http import HTTPStatus
34
from typing import Annotated
45

@@ -41,33 +42,45 @@ def request_group_access(
4142
settings: Annotated[Settings, Depends(get_settings)],
4243
):
4344
"""
44-
Request access to a group. Assumes the user does not already have a
45-
GroupMembership record for this group.
45+
Request access to a group. Users can re-request access if their last
46+
request was rejected; otherwise the request is rejected if a membership
47+
already exists.
4648
"""
4749
group_id = request_data.group_id
4850
existing_membership = GroupMembership.get_by_user_id_and_group_id(
4951
user_id=user.access_token.sub,
5052
group_id=group_id,
5153
session=db_session,
5254
)
55+
membership: GroupMembership
5356
if existing_membership is not None:
54-
raise HTTPException(
55-
status_code=HTTPStatus.CONFLICT,
56-
detail=f"User {user.access_token.sub} already has a membership for {group_id}"
57+
if existing_membership.approval_status != ApprovalStatusEnum.REJECTED:
58+
raise HTTPException(
59+
status_code=HTTPStatus.CONFLICT,
60+
detail=f"User {user.access_token.sub} already has a membership for {group_id}"
61+
)
62+
membership = existing_membership
63+
membership.approval_status = ApprovalStatusEnum.PENDING
64+
membership.rejection_reason = None
65+
membership.updated_by = None
66+
membership.updated_at = datetime.now(timezone.utc)
67+
membership.save(session=db_session, commit=False)
68+
logger.info("Re-requested group membership for %s(%s)", group_id, user.access_token.sub)
69+
else:
70+
group = BiocommonsGroup.get_by_id(group_id, db_session)
71+
user_record = BiocommonsUser.get_or_create(
72+
auth0_id=user.access_token.sub,
73+
db_session=db_session,
74+
auth0_client=auth0_client
5775
)
58-
group = BiocommonsGroup.get_by_id(group_id, db_session)
59-
user_record = BiocommonsUser.get_or_create(
60-
auth0_id=user.access_token.sub,
61-
db_session=db_session,
62-
auth0_client=auth0_client
63-
)
64-
membership = GroupMembership(
65-
group=group,
66-
user=user_record,
67-
approval_status=ApprovalStatusEnum.PENDING,
68-
updated_by=None
69-
)
70-
membership.save(session=db_session, commit=False)
76+
membership = GroupMembership(
77+
group=group,
78+
user=user_record,
79+
approval_status=ApprovalStatusEnum.PENDING,
80+
updated_by=None
81+
)
82+
membership.save(session=db_session, commit=False)
83+
logger.info("Requested group membership for %s(%s)", group_id, user.access_token.sub)
7184
logger.info("Queueing emails to group admins for approval")
7285
admin_emails = membership.group.get_admins(auth0_client=auth0_client)
7386
for email in admin_emails:
@@ -113,8 +126,15 @@ def approve_group_access(
113126
status_code=HTTPStatus.NOT_FOUND,
114127
detail="No membership request found for this user"
115128
)
129+
if membership.approval_status not in {ApprovalStatusEnum.PENDING, ApprovalStatusEnum.REVOKED}:
130+
raise HTTPException(
131+
status_code=HTTPStatus.BAD_REQUEST,
132+
detail="Only pending or revoked group memberships can be approved.",
133+
)
116134
membership.approval_status = ApprovalStatusEnum.APPROVED
117135
membership.updated_by = approving_user_record
136+
membership.rejection_reason = None
137+
membership.revocation_reason = None
118138
membership.grant_auth0_role(auth0_client=auth0_client)
119139
membership.save(session=db_session, commit=False)
120140
if membership.user is None:

scheduled_tasks/tasks.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@
2222
PlatformMembership,
2323
)
2424
from db.setup import get_db_session
25-
from db.types import GROUP_NAMES, ApprovalStatusEnum, EmailStatusEnum, GroupEnum
25+
from db.types import (
26+
GROUP_NAMES,
27+
ApprovalStatusEnum,
28+
EmailStatusEnum,
29+
GroupEnum,
30+
PlatformEnum,
31+
)
2632
from scheduled_tasks.email_retry import (
2733
EMAIL_JOB_ID_PREFIX,
2834
EMAIL_MAX_ATTEMPTS,
@@ -263,6 +269,7 @@ async def sync_auth0_roles():
263269

264270
db_session = next(get_db_session())
265271
auth0_role_ids: set[str] = set()
272+
db_roles_by_name: dict[str, Auth0Role] = {}
266273
try:
267274
for role in roles:
268275
logger.info(f" Role: {role.name}")
@@ -292,18 +299,58 @@ async def sync_auth0_roles():
292299
if db_role.name != role.name or db_role.description != role.description:
293300
db_role.name = role.name
294301
db_role.description = role.description
302+
db_roles_by_name[db_role.name] = db_role
295303
# Soft delete roles missing from Auth0
296304
db_session.flush()
297305
existing_roles = db_session.exec(select(Auth0Role)).all()
298306
for db_role in existing_roles:
299307
if db_role.id not in auth0_role_ids:
300308
logger.info(f" Soft deleting role {db_role.name} ({db_role.id}) absent from Auth0")
301309
db_role.delete(db_session, commit=False)
310+
link_admin_roles(db_session, db_roles_by_name)
302311
db_session.commit()
303312
finally:
304313
db_session.close()
305314

306315

316+
def link_admin_roles(session: Session, db_roles_by_name: dict[str, Auth0Role]) -> None:
317+
"""
318+
Link admin roles to platforms/groups based on naming conventions:
319+
- Platform admin roles: biocommons/role/{platform_id}/admin
320+
- Group admin roles: biocommons/role/{group_short_id}/admin where group_id is biocommons/group/{group_short_id}
321+
"""
322+
platform_admin_pattern = re.compile(
323+
r"^biocommons/role/(?P<platform_id>[a-z0-9_]+)/admin$", re.IGNORECASE
324+
)
325+
group_admin_pattern = re.compile(
326+
r"^biocommons/role/(?P<group_short_id>[a-z0-9_]+)/admin$", re.IGNORECASE
327+
)
328+
329+
for role_name, role in db_roles_by_name.items():
330+
platform_match = platform_admin_pattern.match(role_name)
331+
if platform_match:
332+
pid = platform_match.group("platform_id").lower()
333+
try:
334+
platform_enum = PlatformEnum(pid)
335+
except ValueError:
336+
platform = None
337+
else:
338+
platform = Platform.get_by_id(platform_enum, session)
339+
if platform:
340+
if role not in platform.admin_roles:
341+
platform.admin_roles.append(role)
342+
session.add(platform)
343+
continue
344+
345+
group_match = group_admin_pattern.match(role_name)
346+
if group_match:
347+
gid_short = group_match.group("group_short_id").lower()
348+
full_group_id = f"biocommons/group/{gid_short}"
349+
group = BiocommonsGroup.get_by_id(full_group_id, session)
350+
if group and role not in group.admin_roles:
351+
group.admin_roles.append(role)
352+
session.add(group)
353+
307354
class MembershipSyncStatus(BaseModel):
308355
created: bool
309356
restored: bool
@@ -498,6 +545,10 @@ async def populate_db_groups(groups=GroupEnum):
498545
db_group = BiocommonsGroup(group_id=group.value, name=name, short_name=short_name)
499546
db_session.add(db_group)
500547
db_session.commit()
548+
# Ensure admin roles are linked now that groups exist (sync_auth0_roles may have run earlier)
549+
roles_by_name = {role.name: role for role in Auth0Role.get_all(db_session)}
550+
link_admin_roles(db_session, roles_by_name)
551+
db_session.commit()
501552
finally:
502553
db_session.close()
503554

@@ -528,6 +579,10 @@ async def populate_platforms_from_auth0():
528579
else:
529580
logger.info(f" Updating platform {platform_id} (if needed)")
530581
platform.update_from_auth0_role(db_role, db_session, commit=False)
582+
db_session.commit()
583+
roles_by_name = {role.name: role for role in Auth0Role.get_all(db_session)}
584+
link_admin_roles(db_session, roles_by_name)
585+
db_session.commit()
531586
finally:
532587
db_session.close()
533588

0 commit comments

Comments
 (0)