Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bb28a52
Initial purge of groups/services from app_metadata
marius-mather Sep 17, 2025
4f1c470
Initial purge of user endpoints
marius-mather Sep 17, 2025
33511c4
Remove schemas for service/resource requests
marius-mather Sep 17, 2025
66e092c
Remove service from BPA registration
marius-mather Sep 17, 2025
1cf06c3
Update BPA registration test
marius-mather Sep 17, 2025
779ef28
Update/remove tests that use services/resources
marius-mather Sep 17, 2025
96ca4df
Update admin endpoints to query the DB - more efficient than calling …
marius-mather Sep 17, 2025
55984cd
Update unit tests of admin endpoints
marius-mather Sep 17, 2025
42d50be
Update get_user to return from the database instead of from Auth0
marius-mather Sep 17, 2025
7d04a46
Remove admin endpoints for service/resource approval
marius-mather Sep 17, 2025
55f94a4
Update test of get_user
marius-mather Sep 17, 2025
242e1af
Add endpoints for querying user's platforms
marius-mather Sep 18, 2025
8421927
Style fixes
marius-mather Sep 18, 2025
4ec59c9
Add endpoints for querying user's groups
marius-mather Sep 18, 2025
d8d160f
Tests of platform/group user endpoints
marius-mather Sep 18, 2025
be953dc
Ensure sessions are closed with try/finally
marius-mather Sep 18, 2025
3d9b53d
Fix import in test
marius-mather Sep 18, 2025
c02faa9
Update /all/pending endpoint to return platforms + groups
marius-mather Sep 18, 2025
c4c7c09
Test /all/pending endpoint
marius-mather Sep 18, 2025
efcf178
Update tests/test_user.py
marius-mather Sep 18, 2025
f54fc65
Update tests/test_admin.py
marius-mather Sep 18, 2025
7debc4d
Fix types for query helper functions
marius-mather Sep 18, 2025
707240a
Merge branch 'remove-app-metadata' of github.com:AustralianBioCommons…
marius-mather Sep 18, 2025
8075f01
Add group_name to group response data
marius-mather Sep 18, 2025
13a29d4
Test group name is included in groups response
marius-mather Sep 18, 2025
3e25b37
Cache fetching RSA keys from Auth0 - major source of slowness in our …
marius-mather Sep 19, 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
12 changes: 9 additions & 3 deletions auth/validator.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from functools import lru_cache
from typing import Annotated

import httpx
Expand Down Expand Up @@ -49,10 +50,15 @@ def verify_jwt(token: str, settings: Settings) -> AccessTokenPayload:
return AccessTokenPayload(**payload)


def get_rsa_key(token: str, settings: Settings) -> jwk.RSAKey | None: # type: ignore
jwks_url = f"https://{settings.auth0_domain}/.well-known/jwks.json"
@lru_cache(maxsize=100)
def _fetch_rsa_keys(auth0_domain: str) -> dict:
jwks_url = f"https://{auth0_domain}/.well-known/jwks.json"
response = httpx.get(jwks_url)
jwks = response.json()
return response.json()


def get_rsa_key(token: str, settings: Settings) -> jwk.RSAKey | None: # type: ignore
jwks = _fetch_rsa_keys(settings.auth0_domain)
unverified_header = jwt.get_unverified_header(token)

for key in jwks["keys"]:
Expand Down
5 changes: 4 additions & 1 deletion db/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,7 @@ def create_db_and_tables():
def get_db_session():
engine = get_engine()
with Session(engine) as session:
yield session
try:
yield session
finally:
session.close()
149 changes: 53 additions & 96 deletions routers/admin.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import asyncio
import logging
from datetime import datetime
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi.params import Query
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import func, or_
from sqlalchemy import false, func, or_
from sqlmodel import Session, select

from auth.validator import get_current_user, user_is_admin
from auth.validator import user_is_admin
from auth0.client import Auth0Client, get_auth0_client
from db.models import (
BiocommonsGroup,
Expand All @@ -19,10 +18,8 @@
PlatformMembership,
)
from db.setup import get_db_session
from db.types import GroupEnum
from routers.user import update_user_metadata
from schemas.biocommons import Auth0UserData, Auth0UserDataWithMemberships
from schemas.user import SessionUser
from db.types import ApprovalStatusEnum, GroupEnum
from schemas.biocommons import Auth0UserDataWithMemberships

logger = logging.getLogger('uvicorn.error')

Expand Down Expand Up @@ -50,6 +47,7 @@ class BiocommonsUserResponse(BaseModel):
id: str = Field(description="Auth0 user ID")
email: str = Field(description="User email address")
username: str = Field(description="User username")
email_verified: bool = Field(description="User email verification status")
created_at: datetime = Field(description="User creation timestamp")


Expand Down Expand Up @@ -166,47 +164,75 @@ def get_users(db_session: Annotated[Session, Depends(get_db_session)],


# NOTE: This must appear before /users/{user_id} so it takes precedence
@router.get("/users/approved")
def get_approved_users(client: Annotated[Auth0Client, Depends(get_auth0_client)],
@router.get(
"/users/approved",
response_model=list[BiocommonsUserResponse])
def get_approved_users(db_session: Annotated[Session, Depends(get_db_session)],
pagination: Annotated[PaginationParams, Depends(get_pagination_params)]):
resp = client.get_approved_users(page=pagination.page, per_page=pagination.per_page)
return resp
platform_approved_query = (
select(BiocommonsUser)
.join(PlatformMembership, BiocommonsUser.id == PlatformMembership.user_id)
.where(PlatformMembership.approval_status == ApprovalStatusEnum.APPROVED)
.distinct()
)
user_query = platform_approved_query.offset(pagination.start_index).limit(pagination.per_page)
users = db_session.exec(user_query).all()
return users


@router.get("/users/pending")
def get_pending_users(client: Annotated[Auth0Client, Depends(get_auth0_client)],
@router.get("/users/pending",
response_model=list[BiocommonsUserResponse])
def get_pending_users(db_session: Annotated[Session, Depends(get_db_session)],
pagination: Annotated[PaginationParams, Depends(get_pagination_params)]):
resp = client.get_pending_users(page=pagination.page, per_page=pagination.per_page)
return resp
platform_pending_query = (
select(BiocommonsUser)
.join(PlatformMembership, BiocommonsUser.id == PlatformMembership.user_id)
.where(PlatformMembership.approval_status == ApprovalStatusEnum.PENDING)
.distinct()
)
user_query = platform_pending_query.offset(pagination.start_index).limit(pagination.per_page)
users = db_session.exec(user_query).all()
return users


@router.get("/users/revoked")
def get_revoked_users(client: Annotated[Auth0Client, Depends(get_auth0_client)],
def get_revoked_users(db_session: Annotated[Session, Depends(get_db_session)],
pagination: Annotated[PaginationParams, Depends(get_pagination_params)]):
resp = client.get_revoked_users(page=pagination.page, per_page=pagination.per_page)
return resp
platform_revoked_query = (
select(BiocommonsUser)
.join(PlatformMembership, BiocommonsUser.id == PlatformMembership.user_id)
.where(PlatformMembership.approval_status == ApprovalStatusEnum.REVOKED)
.distinct()
)
user_query = platform_revoked_query.offset(pagination.start_index).limit(pagination.per_page)
users = db_session.exec(user_query).all()
return users


@router.get("/users/unverified", response_model=list[Auth0UserData])
@router.get("/users/unverified", response_model=list[BiocommonsUserResponse])
def get_unverified_users(
client: Annotated[Auth0Client, Depends(get_auth0_client)],
db_session: Annotated[Session, Depends(get_db_session)],
pagination: Annotated[PaginationParams, Depends(get_pagination_params)],
):
"""
Return users whose email is not verified, using Auth0 search for efficiency.
"""
return client.get_users(
page=pagination.page,
per_page=pagination.per_page,
q="email_verified:false",
query = (
select(BiocommonsUser)
.where(BiocommonsUser.email_verified == false())
.offset(pagination.start_index)
.limit(pagination.per_page)
)
users = db_session.exec(query).all()
return users


@router.get("/users/{user_id}",
response_model=Auth0UserData)
response_model=BiocommonsUserResponse)
def get_user(user_id: Annotated[str, UserIdParam],
client: Annotated[Auth0Client, Depends(get_auth0_client)]):
return client.get_user(user_id)
db_session: Annotated[Session, Depends(get_db_session)]):
user = db_session.get_one(BiocommonsUser, user_id)
return user


@router.get("/users/{user_id}/details",
Expand All @@ -233,72 +259,3 @@ def resend_verification_email(user_id: Annotated[str, UserIdParam],
client: Annotated[Auth0Client, Depends(get_auth0_client)]):
client.resend_verification_email(user_id)
return {"message": "Verification email resent."}


@router.post("/users/{user_id}/services/{service_id}/approve")
def approve_service(user_id: Annotated[str, UserIdParam],
service_id: Annotated[str, ServiceIdParam],
client: Annotated[Auth0Client, Depends(get_auth0_client)],
approving_user: Annotated[SessionUser, Depends(get_current_user)]):
user = client.get_user(user_id=user_id)
# Need to fetch full user info currently to get email address, not in access token
approving_user_data = client.get_user(user_id=approving_user.access_token.sub)
logger.debug(f"Approving service {service_id} for user {user_id} by {approving_user_data.email}")
user.app_metadata.approve_service(service_id, updated_by=str(approving_user_data.email))
logger.info("Sending updated metadata to Auth0 API")
# update_user_metadata is async, so run via asyncio
update = update_user_metadata(
user_id=user_id,
token=client.management_token,
metadata=user.app_metadata.model_dump(mode="json")
)
resp = asyncio.run(update)
logger.info("Metadata updated successfully")
return resp


@router.post("/users/{user_id}/services/{service_id}/revoke")
def revoke_service(user_id: Annotated[str, UserIdParam],
service_id: Annotated[str, ServiceIdParam],
client: Annotated[Auth0Client, Depends(get_auth0_client)],
revoking_user: Annotated[SessionUser, Depends(get_current_user)]):
"""
Revoke a service and all associated resources for a user.
"""
user = client.get_user(user_id=user_id)
revoking_user_data = client.get_user(user_id=revoking_user.access_token.sub)
user.app_metadata.revoke_service(service_id=service_id, updated_by=str(revoking_user_data.email))
service = user.app_metadata.get_service_by_id(service_id)
for resource in service.resources:
resource.revoke()
update = update_user_metadata(
user_id=user_id,
token=client.management_token,
metadata=user.app_metadata.model_dump(mode="json")
)
resp = asyncio.run(update)
return resp


@router.post("/users/{user_id}/services/{service_id}/resources/{resource_id}/approve")
def approve_resource(user_id: Annotated[str, UserIdParam],
service_id: Annotated[str, ServiceIdParam],
resource_id: Annotated[str, ResourceIdParam],
client: Annotated[Auth0Client, Depends(get_auth0_client)],
approving_user: Annotated[SessionUser, Depends(get_current_user)]):
user = client.get_user(user_id=user_id)
approving_user_data = client.get_user(user_id=approving_user.access_token.sub)

user.app_metadata.approve_resource(
service_id=service_id,
resource_id=resource_id,
updated_by=approving_user_data.email
)

update = update_user_metadata(
user_id=user_id,
token=client.management_token,
metadata=user.app_metadata.model_dump(mode="json")
)
resp = asyncio.run(update)
return resp
20 changes: 1 addition & 19 deletions routers/bpa_register.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import logging
from datetime import datetime, timezone

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

from auth0.client import Auth0Client, get_auth0_client
from config import Settings, get_settings
from db.models import BiocommonsUser, PlatformEnum
from db.setup import get_db_session
from routers.errors import RegistrationRoute
from schemas.biocommons import Auth0UserData, BiocommonsRegisterData
from schemas.bpa import BPARegistrationRequest
from schemas.responses import RegistrationErrorResponse, RegistrationResponse
from schemas.service import Service

logger = logging.getLogger(__name__)

Expand All @@ -26,17 +23,6 @@
)


def _get_bpa_service_request(registration: BPARegistrationRequest, settings: Settings, update_time: datetime) -> Service:
return Service(
name="Bioplatforms Australia Data Portal",
id="bpa",
initial_request_time=update_time,
status="pending",
last_updated=update_time,
updated_by="system",
)


@router.post(
"/register",
responses={
Expand All @@ -46,17 +32,13 @@ def _get_bpa_service_request(registration: BPARegistrationRequest, settings: Set
)
async def register_bpa_user(
registration: BPARegistrationRequest,
settings: Settings = Depends(get_settings),
db_session: Session = Depends(get_db_session),
auth0_client: Auth0Client = Depends(get_auth0_client)
):
"""Register a new BPA user."""
now = datetime.now(timezone.utc)
bpa_service = _get_bpa_service_request(registration=registration, settings=settings, update_time=now)

# Create Auth0 user data
user_data = BiocommonsRegisterData.from_bpa_registration(
registration=registration, bpa_service=bpa_service
registration=registration
)

try:
Expand Down
Loading