Skip to content

Commit b971da7

Browse files
Merge pull request #109 from AustralianBioCommons/AAI-379-revoke-users-from-TSI-embargo
enable user removal from groups
2 parents daf0687 + f7f0fdb commit b971da7

File tree

6 files changed

+173
-7
lines changed

6 files changed

+173
-7
lines changed

auth0/client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,18 @@ def add_roles_to_user(self, user_id: str, role_id: str | list[str]):
161161
resp.raise_for_status()
162162
return True
163163

164+
def remove_roles_from_user(self, user_id: str, role_id: str | list[str]):
165+
"""
166+
Remove one or more roles from a user.
167+
"""
168+
url = f"https://{self.domain}/api/v2/users/{user_id}/roles"
169+
if isinstance(role_id, str):
170+
role_id = [role_id]
171+
# httpx.Client.delete() no longer accepts json payloads (0.28+), so use request()
172+
resp = self._client.request("DELETE", url, json={"roles": role_id})
173+
resp.raise_for_status()
174+
return True
175+
164176
def search_users_by_email(self, email: str) -> list[Auth0UserData]:
165177
url = f"https://{self.domain}/api/v2/users-by-email"
166178
resp = self._client.get(url, params={"email": email})

db/models.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import uuid
22
from datetime import datetime, timezone
3-
from typing import Self
3+
from typing import Optional, Self
44

55
from pydantic import AwareDatetime
66
from sqlalchemy import Column, String, UniqueConstraint
@@ -536,6 +536,39 @@ def grant_auth0_role(self, auth0_client: Auth0Client):
536536
auth0_client.add_roles_to_user(user_id=self.user_id, role_id=role.id)
537537
return True
538538

539+
def revoke_auth0_role(self, auth0_client: Auth0Client):
540+
"""
541+
Remove the Auth0 role backing this group membership when access is revoked.
542+
"""
543+
if self.approval_status != ApprovalStatusEnum.APPROVED:
544+
return False
545+
role = auth0_client.get_role_by_name(self.group_id)
546+
auth0_client.remove_roles_from_user(user_id=self.user_id, role_id=role.id)
547+
return True
548+
549+
def revoke(
550+
self,
551+
*,
552+
auth0_client: Auth0Client,
553+
reason: str | None,
554+
updated_by: Optional["BiocommonsUser"],
555+
session: Session,
556+
commit: bool = True,
557+
) -> bool:
558+
"""
559+
Revoke this membership by removing its Auth0 role (when applicable) and
560+
persisting the revoked status in the database.
561+
562+
:return: True when an Auth0 role removal call was performed.
563+
"""
564+
role_revoked = self.revoke_auth0_role(auth0_client)
565+
self.approval_status = ApprovalStatusEnum.REVOKED
566+
self.revocation_reason = reason
567+
self.updated_at = datetime.now(timezone.utc)
568+
self.updated_by = updated_by
569+
self.save(session=session, commit=commit)
570+
return role_revoked
571+
539572
def get_data(self) -> GroupMembershipData:
540573
"""
541574
Get a data model for this membership, suitable for returning to the frontend.

routers/admin.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -204,19 +204,26 @@ def _revoke_group_membership(
204204
reason: str | None,
205205
admin_record: BiocommonsUser,
206206
db_session: Session,
207+
client: Auth0Client,
207208
) -> None:
208209
membership = GroupMembership.get_by_user_id_and_group_id_or_404(
209210
user_id=user_id,
210211
group_id=group.group_id,
211212
session=db_session,
212213
)
213-
membership.approval_status = ApprovalStatusEnum.REVOKED
214-
membership.revocation_reason = reason
215-
membership.updated_at = datetime.now(timezone.utc)
216-
membership.updated_by = admin_record
217-
membership.save(session=db_session, commit=True)
214+
role_revoked = membership.revoke(
215+
auth0_client=client,
216+
reason=reason,
217+
updated_by=admin_record,
218+
session=db_session,
219+
)
218220
db_session.refresh(membership)
219-
logger.info("Revoked group %s for user %s", group.group_id, user_id)
221+
logger.info(
222+
"Revoked group %s for user %s%s",
223+
group.group_id,
224+
user_id,
225+
"" if role_revoked else " (no Auth0 role assigned)",
226+
)
220227

221228

222229
@router.get("/filters")
@@ -623,6 +630,7 @@ def approve_group_membership(user_id: Annotated[str, UserIdParam],
623630
def revoke_group_membership(user_id: Annotated[str, UserIdParam],
624631
group_id: Annotated[str, ServiceIdParam],
625632
payload: RevokeServiceRequest,
633+
client: Annotated[Auth0Client, Depends(get_auth0_client)],
626634
admin_record: Annotated[BiocommonsUser, Depends(get_db_user)],
627635
db_session: Annotated[Session, Depends(get_db_session)]):
628636
group_record = BiocommonsGroup.get_by_id_or_404(group_id, session=db_session)
@@ -632,6 +640,7 @@ def revoke_group_membership(user_id: Annotated[str, UserIdParam],
632640
reason=payload.reason,
633641
admin_record=admin_record,
634642
db_session=db_session,
643+
client=client,
635644
)
636645
return _membership_response()
637646

tests/db/test_models.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,91 @@ def test_group_membership_grant_auth0_role_not_approved(status, test_auth0_clien
665665
membership_request.grant_auth0_role(test_auth0_client)
666666

667667

668+
@respx.mock
669+
def test_group_membership_revoke_auth0_role(test_auth0_client, persistent_factories):
670+
group = BiocommonsGroupFactory.create_sync(group_id="biocommons/group/tsi", admin_roles=[])
671+
user = BiocommonsUserFactory.create_sync(group_memberships=[])
672+
membership = GroupMembershipFactory.create_sync(group=group, user=user, approval_status=ApprovalStatusEnum.APPROVED.value)
673+
role_data = RoleDataFactory.build(name=group.group_id)
674+
role_lookup = respx.get(
675+
"https://auth0.example.com/api/v2/roles",
676+
params={"name_filter": group.group_id}
677+
).respond(status_code=200, json=[role_data.model_dump(mode="json")])
678+
route = respx.delete(f"https://auth0.example.com/api/v2/users/{user.id}/roles").respond(status_code=200)
679+
assert membership.revoke_auth0_role(test_auth0_client)
680+
assert role_lookup.called
681+
assert route.called
682+
683+
684+
@respx.mock
685+
def test_group_membership_revoke_updates_state(test_db_session, test_auth0_client, persistent_factories):
686+
group = BiocommonsGroupFactory.create_sync(group_id="biocommons/group/tsi", admin_roles=[])
687+
admin = BiocommonsUserFactory.create_sync()
688+
user = BiocommonsUserFactory.create_sync(group_memberships=[])
689+
membership = GroupMembershipFactory.create_sync(group=group, user=user, approval_status=ApprovalStatusEnum.APPROVED.value)
690+
role_data = RoleDataFactory.build(name=group.group_id)
691+
respx.get(
692+
"https://auth0.example.com/api/v2/roles",
693+
params={"name_filter": group.group_id}
694+
).respond(status_code=200, json=[role_data.model_dump(mode="json")])
695+
route = respx.delete(f"https://auth0.example.com/api/v2/users/{user.id}/roles").respond(status_code=200)
696+
697+
result = membership.revoke(
698+
auth0_client=test_auth0_client,
699+
reason="No longer required",
700+
updated_by=admin,
701+
session=test_db_session,
702+
)
703+
704+
assert result is True
705+
assert route.called
706+
test_db_session.refresh(membership)
707+
assert membership.approval_status == ApprovalStatusEnum.REVOKED
708+
assert membership.revocation_reason == "No longer required"
709+
assert membership.updated_by_id == admin.id
710+
711+
712+
@pytest.mark.parametrize("status", ["pending", "revoked"])
713+
@respx.mock
714+
def test_group_membership_revoke_skips_auth0_when_not_approved(status, test_db_session, test_auth0_client, persistent_factories):
715+
group = BiocommonsGroupFactory.create_sync(group_id="biocommons/group/tsi", admin_roles=[])
716+
admin = BiocommonsUserFactory.create_sync()
717+
user = BiocommonsUserFactory.create_sync(group_memberships=[])
718+
membership = GroupMembershipFactory.create_sync(group=group, user=user, approval_status=status)
719+
role_lookup = respx.get(
720+
"https://auth0.example.com/api/v2/roles",
721+
params={"name_filter": group.group_id}
722+
).respond(status_code=200, json=[])
723+
724+
result = membership.revoke(
725+
auth0_client=test_auth0_client,
726+
reason="Policy update",
727+
updated_by=admin,
728+
session=test_db_session,
729+
)
730+
731+
assert result is False
732+
assert not role_lookup.called
733+
test_db_session.refresh(membership)
734+
assert membership.approval_status == ApprovalStatusEnum.REVOKED
735+
assert membership.revocation_reason == "Policy update"
736+
assert membership.updated_by_id == admin.id
737+
738+
739+
@pytest.mark.parametrize("status", ["pending", "revoked"])
740+
@respx.mock
741+
def test_group_membership_revoke_auth0_role_not_approved(status, test_auth0_client, persistent_factories):
742+
group = BiocommonsGroupFactory.create_sync(group_id="biocommons/group/tsi", admin_roles=[])
743+
user = Auth0UserDataFactory.build()
744+
membership = GroupMembershipFactory.create_sync(group=group, user_id=user.user_id, approval_status=status)
745+
role_lookup = respx.get(
746+
"https://auth0.example.com/api/v2/roles",
747+
params={"name_filter": group.group_id}
748+
).respond(status_code=200, json=[])
749+
assert membership.revoke_auth0_role(test_auth0_client) is False
750+
assert not role_lookup.called
751+
752+
668753
def test_group_membership_save_with_history(test_db_session, persistent_factories):
669754
group = BiocommonsGroupFactory.create_sync()
670755
membership = GroupMembershipFactory.build(group_id=group.group_id)

tests/test_admin.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from db.types import ApprovalStatusEnum, GroupEnum, PlatformEnum
1515
from main import app
1616
from routers.admin import PaginationParams
17+
from tests.biocommons.datagen import RoleDataFactory
1718
from tests.datagen import (
1819
AccessTokenPayloadFactory,
1920
Auth0UserDataFactory,
@@ -770,6 +771,7 @@ def test_revoke_group_membership_records_reason(
770771
admin_user,
771772
tsi_group,
772773
persistent_factories,
774+
mock_auth0_client,
773775
):
774776
user = BiocommonsUserFactory.create_sync(group_memberships=[])
775777
membership = GroupMembershipFactory.create_sync(
@@ -784,6 +786,9 @@ def test_revoke_group_membership_records_reason(
784786
)
785787
test_db_session.commit()
786788

789+
mock_role = RoleDataFactory.build(name=tsi_group.group_id)
790+
mock_auth0_client.get_role_by_name.return_value = mock_role
791+
787792
reason = "Access no longer required"
788793
resp = test_client.post(
789794
f"/admin/users/{user.id}/groups/{tsi_group.group_id}/revoke",
@@ -797,13 +802,19 @@ def test_revoke_group_membership_records_reason(
797802
assert membership.approval_status == ApprovalStatusEnum.REVOKED
798803
assert membership.revocation_reason == reason
799804
assert membership.updated_by_id == admin_db_user.id
805+
mock_auth0_client.get_role_by_name.assert_called_once_with(tsi_group.group_id)
806+
mock_auth0_client.remove_roles_from_user.assert_called_once_with(
807+
user_id=user.id,
808+
role_id=mock_role.id,
809+
)
800810

801811

802812
def test_revoke_group_membership_forbidden_without_group_role(
803813
test_client,
804814
test_db_session,
805815
tsi_group,
806816
persistent_factories,
817+
mock_auth0_client,
807818
):
808819
user = BiocommonsUserFactory.create_sync(group_memberships=[])
809820
membership = GroupMembershipFactory.create_sync(
@@ -843,6 +854,8 @@ def test_revoke_group_membership_forbidden_without_group_role(
843854
test_db_session.refresh(membership)
844855
assert membership.approval_status == original_status
845856
assert membership.revocation_reason == original_reason
857+
mock_auth0_client.get_role_by_name.assert_not_called()
858+
mock_auth0_client.remove_roles_from_user.assert_not_called()
846859

847860

848861
def test_resend_verification_email(test_client, as_admin_user, test_db_session, galaxy_platform, mock_auth0_client):

tests/test_auth0_client.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,20 @@ def test_add_roles_to_user(test_auth0_client):
142142
assert call_data == b'{"roles":["' +role_id.encode() + b'"]}'
143143

144144

145+
@respx.mock
146+
def test_remove_roles_from_user(test_auth0_client):
147+
"""
148+
Test we can remove roles from a user in Auth0 API
149+
"""
150+
user_id = random_auth0_id()
151+
role_id = random_auth0_role_id()
152+
route = respx.delete(f"https://auth0.example.com/api/v2/users/{user_id}/roles").respond(204)
153+
test_auth0_client.remove_roles_from_user(user_id, role_id)
154+
assert route.called
155+
call_data = route.calls[0].request.content
156+
assert call_data == b'{"roles":["' + role_id.encode() + b'"]}'
157+
158+
145159
@respx.mock
146160
def test_create_user(test_auth0_client):
147161
"""

0 commit comments

Comments
 (0)