Skip to content

Commit f3898ce

Browse files
feat: enable revoke access via updating db
1 parent 0890d6b commit f3898ce

File tree

5 files changed

+301
-36
lines changed

5 files changed

+301
-36
lines changed

db/models.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Self
44

55
from pydantic import AwareDatetime
6-
from sqlalchemy import UniqueConstraint
6+
from sqlalchemy import Column, String, UniqueConstraint
77
from sqlmodel import DateTime, Field, Relationship, Session, select
88
from sqlmodel import Enum as DbEnum
99

@@ -157,6 +157,10 @@ class PlatformMembership(BaseModel, table=True):
157157
"foreign_keys": "PlatformMembership.updated_by_id",
158158
}
159159
)
160+
revocation_reason: str | None = Field(
161+
default=None,
162+
sa_column=Column(String(1024), nullable=True)
163+
)
160164

161165
def save_history(self, session: Session) -> "PlatformMembershipHistory":
162166
# Make sure this object is in the session before accessing relationships
@@ -170,6 +174,7 @@ def save_history(self, session: Session) -> "PlatformMembershipHistory":
170174
approval_status=self.approval_status,
171175
updated_at=self.updated_at,
172176
updated_by=self.updated_by,
177+
reason=self.revocation_reason,
173178
)
174179
session.add(history)
175180
return history
@@ -188,6 +193,7 @@ def get_data(self) -> PlatformMembershipData:
188193
user_id=self.user_id,
189194
approval_status=self.approval_status,
190195
updated_by=updated_by,
196+
revocation_reason=self.revocation_reason,
191197
)
192198

193199

@@ -212,6 +218,10 @@ class PlatformMembershipHistory(BaseModel, table=True):
212218
"foreign_keys": "PlatformMembershipHistory.updated_by_id",
213219
}
214220
)
221+
reason: str | None = Field(
222+
default=None,
223+
sa_column=Column(String(1024), nullable=True)
224+
)
215225

216226

217227
class GroupMembership(BaseModel, table=True):
@@ -247,6 +257,10 @@ class GroupMembership(BaseModel, table=True):
247257
"foreign_keys": "GroupMembership.updated_by_id",
248258
}
249259
)
260+
revocation_reason: str | None = Field(
261+
default=None,
262+
sa_column=Column(String(1024), nullable=True)
263+
)
250264

251265
@classmethod
252266
def get_by_user_id(
@@ -283,6 +297,7 @@ def save_history(
283297
approval_status=self.approval_status,
284298
updated_at=self.updated_at,
285299
updated_by=self.updated_by,
300+
reason=self.revocation_reason,
286301
)
287302
session.add(history)
288303
if commit:
@@ -310,6 +325,7 @@ def get_data(self) -> GroupMembershipData:
310325
group_name=self.group.name,
311326
approval_status=self.approval_status,
312327
updated_by=updated_by,
328+
revocation_reason=self.revocation_reason,
313329
)
314330

315331

@@ -340,6 +356,10 @@ class GroupMembershipHistory(BaseModel, table=True):
340356
"foreign_keys": "GroupMembershipHistory.updated_by_id",
341357
}
342358
)
359+
reason: str | None = Field(
360+
default=None,
361+
sa_column=Column(String(1024), nullable=True)
362+
)
343363

344364
@classmethod
345365
def get_by_user_id(

db/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class PlatformMembershipData(BaseModel):
2929
user_id: str
3030
approval_status: ApprovalStatusEnum
3131
updated_by: str
32+
revocation_reason: str | None = None
3233

3334

3435
class GroupMembershipData(BaseModel):
@@ -38,6 +39,7 @@ class GroupMembershipData(BaseModel):
3839
group_name: str
3940
approval_status: ApprovalStatusEnum
4041
updated_by: str
42+
revocation_reason: str | None = None
4143

4244

4345
# Not used for Groups in the database yet

routers/admin.py

Lines changed: 156 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import asyncio
22
import logging
3-
from datetime import datetime
3+
from datetime import datetime, timezone
44
from typing import Annotated
55

66
from fastapi import APIRouter, Depends, HTTPException, Path
@@ -96,6 +96,76 @@ def strip_reason(cls, value: str | None) -> str | None:
9696
return stripped or None
9797

9898

99+
def _resolve_platform(service_id: str) -> PlatformEnum | None:
100+
if service_id in PLATFORM_MAPPING:
101+
return PLATFORM_MAPPING[service_id]["enum"]
102+
for data in PLATFORM_MAPPING.values():
103+
if data["enum"].value == service_id:
104+
return data["enum"]
105+
return None
106+
107+
108+
def _resolve_group(service_id: str) -> GroupEnum | None:
109+
if service_id in GROUP_MAPPING:
110+
return GROUP_MAPPING[service_id]["enum"]
111+
for data in GROUP_MAPPING.values():
112+
if data["enum"].value == service_id:
113+
return data["enum"]
114+
return None
115+
116+
117+
def _get_or_create_db_user(user_id: str,
118+
client: Auth0Client,
119+
db_session: Session) -> BiocommonsUser:
120+
db_user = db_session.get(BiocommonsUser, user_id)
121+
if db_user is None:
122+
db_user = BiocommonsUser.get_or_create(
123+
auth0_id=user_id,
124+
db_session=db_session,
125+
auth0_client=client,
126+
)
127+
return db_user
128+
129+
130+
def _get_platform_membership_or_404(
131+
*, user_id: str, platform_id: PlatformEnum, db_session: Session
132+
) -> PlatformMembership:
133+
membership = db_session.exec(
134+
select(PlatformMembership).where(
135+
PlatformMembership.user_id == user_id,
136+
PlatformMembership.platform_id == platform_id,
137+
)
138+
).one_or_none()
139+
if membership is None:
140+
raise HTTPException(
141+
status_code=404,
142+
detail=f"Platform membership '{platform_id.value}' not found for user '{user_id}'",
143+
)
144+
return membership
145+
146+
147+
def _get_group_membership_or_404(
148+
*, user_id: str, group_id: str, db_session: Session
149+
) -> GroupMembership:
150+
membership = GroupMembership.get_by_user_id(
151+
user_id=user_id,
152+
group_id=group_id,
153+
session=db_session,
154+
)
155+
if membership is None:
156+
raise HTTPException(
157+
status_code=404,
158+
detail=f"Group membership '{group_id}' not found for user '{user_id}'",
159+
)
160+
return membership
161+
162+
163+
def _membership_response(kind: str, membership_model) -> dict[str, object]:
164+
data = membership_model.get_data().model_dump(mode="json")
165+
data["type"] = kind
166+
return data
167+
168+
99169
@router.get("/filters")
100170
def get_filter_options():
101171
"""
@@ -299,52 +369,105 @@ def resend_verification_email(user_id: Annotated[str, UserIdParam],
299369
def approve_service(user_id: Annotated[str, UserIdParam],
300370
service_id: Annotated[str, ServiceIdParam],
301371
client: Annotated[Auth0Client, Depends(get_auth0_client)],
302-
approving_user: Annotated[SessionUser, Depends(get_current_user)]):
303-
user = client.get_user(user_id=user_id)
304-
# Need to fetch full user info currently to get email address, not in access token
305-
approving_user_data = client.get_user(user_id=approving_user.access_token.sub)
306-
logger.debug(f"Approving service {service_id} for user {user_id} by {approving_user_data.email}")
307-
user.app_metadata.approve_service(service_id, updated_by=str(approving_user_data.email))
308-
logger.info("Sending updated metadata to Auth0 API")
309-
# update_user_metadata is async, so run via asyncio
310-
update = update_user_metadata(
372+
approving_user: Annotated[SessionUser, Depends(get_current_user)],
373+
db_session: Annotated[Session, Depends(get_db_session)]):
374+
platform = _resolve_platform(service_id)
375+
group = _resolve_group(service_id)
376+
if platform is None and group is None:
377+
raise HTTPException(status_code=404, detail=f"Service '{service_id}' is not recognised")
378+
379+
admin_record = _get_or_create_db_user(
380+
user_id=approving_user.access_token.sub,
381+
client=client,
382+
db_session=db_session,
383+
)
384+
385+
if platform is not None:
386+
membership = _get_platform_membership_or_404(
387+
user_id=user_id,
388+
platform_id=platform,
389+
db_session=db_session,
390+
)
391+
membership.approval_status = ApprovalStatusEnum.APPROVED
392+
membership.revocation_reason = None
393+
membership.updated_at = datetime.now(timezone.utc)
394+
membership.updated_by = admin_record
395+
db_session.add(membership)
396+
membership.save_history(db_session)
397+
db_session.commit()
398+
db_session.refresh(membership)
399+
logger.info("Approved platform %s for user %s", platform.value, user_id)
400+
return _membership_response("platform", membership)
401+
402+
membership = _get_group_membership_or_404(
311403
user_id=user_id,
312-
token=client.management_token,
313-
metadata=user.app_metadata.model_dump(mode="json")
404+
group_id=group.value,
405+
db_session=db_session,
314406
)
315-
resp = asyncio.run(update)
316-
logger.info("Metadata updated successfully")
317-
return resp
407+
membership.approval_status = ApprovalStatusEnum.APPROVED
408+
membership.revocation_reason = None
409+
membership.updated_at = datetime.now(timezone.utc)
410+
membership.updated_by = admin_record
411+
membership.grant_auth0_role(auth0_client=client)
412+
membership.save(session=db_session, commit=True)
413+
db_session.refresh(membership)
414+
logger.info("Approved group %s for user %s", group.value, user_id)
415+
return _membership_response("group", membership)
318416

319417

320418
@router.post("/users/{user_id}/services/{service_id}/revoke")
321419
def revoke_service(user_id: Annotated[str, UserIdParam],
322420
service_id: Annotated[str, ServiceIdParam],
323421
payload: RevokeServiceRequest,
324422
client: Annotated[Auth0Client, Depends(get_auth0_client)],
325-
revoking_user: Annotated[SessionUser, Depends(get_current_user)]):
423+
revoking_user: Annotated[SessionUser, Depends(get_current_user)],
424+
db_session: Annotated[Session, Depends(get_db_session)]):
326425
"""
327-
Revoke a service and all associated resources for a user.
426+
Revoke a service by updating platform or group membership state in the database.
328427
"""
329-
user = client.get_user(user_id=user_id)
330-
revoking_user_data = client.get_user(user_id=revoking_user.access_token.sub)
331-
user.app_metadata.revoke_service(
332-
service_id=service_id,
333-
updated_by=str(revoking_user_data.email),
334-
reason=payload.reason,
428+
platform = _resolve_platform(service_id)
429+
group = _resolve_group(service_id)
430+
if platform is None and group is None:
431+
raise HTTPException(status_code=404, detail=f"Service '{service_id}' is not recognised")
432+
433+
admin_record = _get_or_create_db_user(
434+
user_id=revoking_user.access_token.sub,
435+
client=client,
436+
db_session=db_session,
335437
)
336-
service = user.app_metadata.get_service_by_id(service_id)
337-
if service is None:
338-
raise HTTPException(status_code=404, detail=f"Service '{service_id}' not found for user '{user_id}'")
339-
for resource in service.resources:
340-
resource.revoke()
341-
update = update_user_metadata(
438+
439+
reason = payload.reason
440+
441+
if platform is not None:
442+
membership = _get_platform_membership_or_404(
443+
user_id=user_id,
444+
platform_id=platform,
445+
db_session=db_session,
446+
)
447+
membership.approval_status = ApprovalStatusEnum.REVOKED
448+
membership.revocation_reason = reason
449+
membership.updated_at = datetime.now(timezone.utc)
450+
membership.updated_by = admin_record
451+
db_session.add(membership)
452+
membership.save_history(db_session)
453+
db_session.commit()
454+
db_session.refresh(membership)
455+
logger.info("Revoked platform %s for user %s", platform.value, user_id)
456+
return _membership_response("platform", membership)
457+
458+
membership = _get_group_membership_or_404(
342459
user_id=user_id,
343-
token=client.management_token,
344-
metadata=user.app_metadata.model_dump(mode="json")
460+
group_id=group.value,
461+
db_session=db_session,
345462
)
346-
resp = asyncio.run(update)
347-
return resp
463+
membership.approval_status = ApprovalStatusEnum.REVOKED
464+
membership.revocation_reason = reason
465+
membership.updated_at = datetime.now(timezone.utc)
466+
membership.updated_by = admin_record
467+
membership.save(session=db_session, commit=True)
468+
db_session.refresh(membership)
469+
logger.info("Revoked group %s for user %s", group.value, user_id)
470+
return _membership_response("group", membership)
348471

349472

350473
@router.post("/users/{user_id}/services/{service_id}/resources/{resource_id}/approve")

tests/db/test_models.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
BiocommonsUserFactory,
3131
GroupMembershipFactory,
3232
PlatformFactory,
33+
PlatformMembershipFactory,
3334
)
3435

3536
FROZEN_TIME = datetime(2025, 1, 1, 12, 0, 0)
@@ -143,6 +144,7 @@ def test_create_platform_membership(test_db_session, persistent_factories, froze
143144
# Check the related platform object is populated
144145
assert membership.platform.id == "galaxy"
145146
assert membership.updated_at == FROZEN_TIME
147+
assert membership.revocation_reason is None
146148

147149

148150
def test_create_platform_membership_history(test_db_session, persistent_factories, frozen_time):
@@ -187,6 +189,24 @@ def test_create_group_membership(test_db_session, persistent_factories):
187189
assert membership.updated_by_id == updater.id
188190

189191

192+
def test_platform_membership_save_history_stores_reason(test_db_session, persistent_factories):
193+
membership = PlatformMembershipFactory.create_sync(
194+
approval_status=ApprovalStatusEnum.REVOKED,
195+
revocation_reason="Policy violation",
196+
)
197+
membership.save_history(test_db_session)
198+
history = test_db_session.exec(
199+
select(PlatformMembershipHistory)
200+
.where(
201+
PlatformMembershipHistory.user_id == membership.user_id,
202+
PlatformMembershipHistory.platform_id == membership.platform_id,
203+
)
204+
.order_by(PlatformMembershipHistory.updated_at.desc())
205+
).first()
206+
assert history is not None
207+
assert history.reason == "Policy violation"
208+
209+
190210
def test_create_group_membership_no_updater(test_db_session, persistent_factories):
191211
"""
192212
Test creating a group membership without an updated_by (for automatic approvals)
@@ -366,6 +386,7 @@ def test_group_membership_save_with_history(test_db_session, persistent_factorie
366386
).one()
367387
assert history.group_id == membership.group_id
368388
assert history.user_id == membership.user_id
389+
assert history.reason == membership.revocation_reason
369390

370391

371392
def test_group_membership_save_and_commit_history(test_db_session, persistent_factories):
@@ -381,3 +402,4 @@ def test_group_membership_save_and_commit_history(test_db_session, persistent_fa
381402
).one()
382403
assert history.group_id == membership.group_id
383404
assert history.user_id == membership.user_id
405+
assert history.reason == membership.revocation_reason

0 commit comments

Comments
 (0)