Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1912643
Give get_management_token a dependency on get_settings so it can be o…
marius-mather May 26, 2025
52897bd
Rework Auth0Client to get the management token automatically
marius-mather May 26, 2025
ed48205
Add get_user endpoint
marius-mather May 26, 2025
4d1ba25
Make as_admin_user mock the management token
marius-mather May 26, 2025
b0ba5d7
Update tests
marius-mather May 26, 2025
fe9f6db
Add get_approved/get_pending users to Auth0Client
marius-mather May 26, 2025
ad95e12
Add endpoints to admin router (and make sure approved/pending match b…
marius-mather May 26, 2025
9a4938a
Test get_approved_users
marius-mather May 26, 2025
85dec75
Add freezegun for testing datetimes
marius-mather May 27, 2025
4d63844
Add uv lock file
marius-mather May 27, 2025
828a233
Add approve service admin endpoint
marius-mather May 27, 2025
2b23e74
Add Service.approve() to set metadata for approvals
marius-mather May 27, 2025
579ccf4
Add approve_service() to AppMetadata
marius-mather May 27, 2025
b1a3c9b
Make sure we generate valid Auth0 user IDs
marius-mather May 27, 2025
86e0f8f
Test approve service endpoint
marius-mather May 27, 2025
e9c99e6
Test Service/AppMetadata methods
marius-mather May 27, 2025
ccea3c9
Add methods to approve resources
marius-mather May 28, 2025
7283e74
Test resource approval
marius-mather May 28, 2025
0ec49b7
Specify AppMetadata structure in Auth0UserResponse
marius-mather May 28, 2025
cd622a8
Update where we use app_metadata in the endpoint
marius-mather May 28, 2025
e3e181b
Add methods to revoke a service
marius-mather May 28, 2025
b622cbc
Tests for revoking a service
marius-mather May 28, 2025
e0e805f
Add revoke service endpoint
marius-mather May 28, 2025
a545377
Test revoke service endpoint
marius-mather May 28, 2025
bae6e75
Rework tests to reduce duplication
marius-mather May 28, 2025
5720e56
Add endpoint for fetching revoked users
marius-mather May 28, 2025
e448210
Add tests of approved/pending/revoked users
marius-mather May 28, 2025
7e447d1
Fix approve/revoke endpoints to be POST requests
marius-mather May 30, 2025
4f11776
Rework admin endpoints with reusable path params
marius-mather Jun 1, 2025
7dd05c9
Revoke resources when revoking a service
marius-mather Jun 1, 2025
4d53c8e
New endpoint to approve a resource
marius-mather Jun 2, 2025
ba366fb
Resource.revoke() method
marius-mather Jun 2, 2025
833c217
Test approving resource
marius-mather Jun 2, 2025
a76124f
Fix URLs in tests
marius-mather Jun 2, 2025
d6b3165
Style fixes
marius-mather Jun 2, 2025
decf3dd
Rework all endpoints to use Annotated dependencies
marius-mather Jun 2, 2025
50ebe21
Rename approved_by -> updated_by for consistency
marius-mather Jun 2, 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
7 changes: 5 additions & 2 deletions auth/management.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from typing import Annotated

import httpx
from fastapi import Depends

from .config import Settings
from .config import Settings, get_settings


def get_management_token(settings: Settings):
def get_management_token(settings: Annotated[Settings, Depends(get_settings)]):
url = f"https://{settings.auth0_domain}/oauth/token"
payload = {
"grant_type": "client_credentials",
Expand Down
42 changes: 37 additions & 5 deletions auth0/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,43 @@

class Auth0Client:

def __init__(self, domain: str):
def __init__(self, domain: str, management_token: str):
self.domain = domain
self.management_token = management_token
self._client = httpx.Client(headers={"Authorization": f"Bearer {management_token}"})

def get_users(self, access_token: str) -> list[Auth0UserResponse]:
@staticmethod
def _convert_users(resp: httpx.Response):
"""Convert a list of Auth0UserResponse objects from a response."""
return [Auth0UserResponse(**user) for user in resp.json()]

def get_users(self) -> list[Auth0UserResponse]:
url = f"https://{self.domain}/api/v2/users"
resp = self._client.get(url)
return self._convert_users(resp)

def get_user(self, user_id: str) -> Auth0UserResponse:
url = f"https://{self.domain}/api/v2/users/{user_id}"
resp = self._client.get(url)
return Auth0UserResponse(**resp.json())

def get_approved_users(self) -> list[Auth0UserResponse]:
# TODO: also search for approved resources? (with OR)
approved_query = 'app_metadata.services.status:"approved"'
url = f"https://{self.domain}/api/v2/users"
# TODO: set primary_order=false for faster search?
# https://auth0.com/docs/manage-users/user-search/user-search-best-practices
resp = self._client.get(url, params={"q": approved_query, "search_engine": "v3"})
return self._convert_users(resp)

def get_pending_users(self) -> list[Auth0UserResponse]:
pending_query = 'app_metadata.services.status:"pending"'
url = f"https://{self.domain}/api/v2/users"
resp = self._client.get(url, params={"q": pending_query, "search_engine": "v3"})
return [Auth0UserResponse(**user) for user in resp.json()]

def get_revoked_users(self) -> list[Auth0UserResponse]:
revoked_query = 'app_metadata.services.status:"revoked"'
url = f"https://{self.domain}/api/v2/users"
headers = {"Authorization": f"Bearer {access_token}"}
resp = httpx.get(url, headers=headers)
return resp.json()
resp = self._client.get(url, params={"q": revoked_query, "search_engine": "v3"})
return [Auth0UserResponse(**user) for user in resp.json()]
6 changes: 4 additions & 2 deletions auth0/schemas.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from datetime import datetime
from typing import List, Optional

from pydantic import BaseModel, ConfigDict, EmailStr
from pydantic import BaseModel, ConfigDict, EmailStr, Field

from schemas.service import AppMetadata


class Auth0UserResponse(BaseModel):
Expand All @@ -19,7 +21,7 @@ class Auth0UserResponse(BaseModel):
created_at: datetime
updated_at: datetime
identities: List[dict]
app_metadata: Optional[dict] = None
app_metadata: AppMetadata = Field(default_factory=AppMetadata)
user_metadata: Optional[dict] = None
picture: Optional[str] = None
name: Optional[str] = None
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ dev = [
"pytest-cov>=4.1.0",
"ruff>=0.4.4",
"polyfactory>=2.21.0",
"pre-commit>=3.7.0"
"pre-commit>=3.7.0",
"freezegun>=1.5.2",
]

[tool.pytest.ini_options]
Expand Down
117 changes: 109 additions & 8 deletions routers/admin.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,124 @@
from fastapi import APIRouter, Depends
import asyncio
import logging
from typing import Annotated

from fastapi import APIRouter, Depends, Path

from auth.config import Settings, get_settings
from auth.management import get_management_token
from auth.validator import user_is_admin
from auth.validator import get_current_user, user_is_admin
from auth0.client import Auth0Client
from auth0.schemas import Auth0UserResponse
from routers.user import update_user_metadata
from schemas.user import User

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


UserIdParam = Path(..., pattern=r"^auth0\\|[a-zA-Z0-9]+$")
ServiceIdParam = Path(..., pattern=r"^[-a-zA-Z0-9_]+$")
ResourceIdParam = Path(..., pattern=r"^[-a-zA-Z0-9_]+$")

router = APIRouter(prefix="/admin", tags=["admin"],
dependencies=[Depends(user_is_admin)])


def get_auth0_client(settings: Settings = Depends(get_settings)):
return Auth0Client(settings.auth0_domain)
def get_auth0_client(settings: Settings = Depends(get_settings),
management_token: str = Depends(get_management_token)):
return Auth0Client(settings.auth0_domain, management_token=management_token)


# TODO: May need to paginate this response to make sure we get all
# of them
@router.get("/users",
response_model=list[Auth0UserResponse])
def get_users(settings: Settings = Depends(get_settings),
client: Auth0Client = Depends(get_auth0_client)):
token = get_management_token(settings=settings)
resp = client.get_users(token)
def get_users(client: Auth0Client = Depends(get_auth0_client)):
resp = client.get_users()
return resp


# 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)]):
resp = client.get_approved_users()
return resp


@router.get("/users/pending")
def get_pending_users(client: Annotated[Auth0Client, Depends(get_auth0_client)]):
resp = client.get_pending_users()
return resp


@router.get("/users/revoked")
def get_revoked_users(client: Annotated[Auth0Client, Depends(get_auth0_client)]):
resp = client.get_revoked_users()
return resp


@router.get("/users/{user_id}",
response_model=Auth0UserResponse)
def get_user(user_id: Annotated[str, UserIdParam],
client: Annotated[Auth0Client, Depends(get_auth0_client)]):
return client.get_user(user_id)


@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[User, 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[User, 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)]):
user = client.get_user(user_id=user_id)
user.app_metadata.approve_resource(service_id=service_id, resource_id=resource_id)
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
64 changes: 64 additions & 0 deletions schemas/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ class Resource(BaseModel):
status: Literal["approved", "revoked", "pending"]
id: str

def approve(self):
self.status = "approved"

def revoke(self):
self.status = "revoked"


class Service(BaseModel):
name: str
Expand All @@ -18,13 +24,42 @@ class Service(BaseModel):
updated_by: str
resources: List[Resource] = Field(default_factory=list)

def approve(self, updated_by: str):
self.status = "approved"
self.updated_by = updated_by
self.last_updated = datetime.now()

def revoke(self, updated_by: str):
self.status = "revoked"
self.updated_by = updated_by
self.last_updated = datetime.now()

def approve_resource(self, resource_id: str):
if not self.status == "approved":
raise PermissionError("Service must be approved before approving a resource.")
resource = self.get_resource_by_id(resource_id)
if resource:
resource.approve()
self.last_updated = datetime.now()
return resource
else:
raise ValueError("Resource not found.")

def get_resource_by_id(self, resource_id: str) -> Optional[Resource]:
return next((r for r in self.resources if r.id == resource_id), None)


class Group(BaseModel):
name: str
id: str


class AppMetadata(BaseModel):
"""
app_metadata we use to manage service/resource requests.
Note we expect all app_metadata from Auth0 to match this format
(if not empty).
"""
groups: List[Group] = Field(default_factory=list)
services: List[Service] = Field(default_factory=list)

Expand Down Expand Up @@ -52,6 +87,35 @@ def get_service_by_id(self, service_id: str) -> Optional[Service]:
"""Get a service by its ID."""
return next((s for s in self.services if s.id == service_id), None)

def get_resource_by_id(self, service_id: str, resource_id: str) -> Optional[Resource]:
"""Get a resource by its ID."""
service = self.get_service_by_id(service_id)
if service:
return service.get_resource_by_id(resource_id)
else:
return None

def approve_service(self, service_id: str, updated_by: str):
"""Approve a service by its ID."""
service = self.get_service_by_id(service_id)
if service:
service.approve(updated_by)

def revoke_service(self, service_id: str, updated_by: str):
"""Revoke a service by its ID."""
service = self.get_service_by_id(service_id)
if service:
service.revoke(updated_by=updated_by)

def approve_resource(self, service_id: str, resource_id: str):
"""Approve a resource by its ID."""
resource = self.get_resource_by_id(service_id=service_id, resource_id=resource_id)
if resource:
resource.approve()
return resource
else:
raise ValueError("Resource not found.")


class Identity(BaseModel):
connection: str
Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from fastapi.testclient import TestClient

from auth.config import Settings, get_settings
from auth.management import get_management_token
from auth.validator import get_current_user
from main import app
from tests.datagen import AccessTokenPayloadFactory, UserFactory
Expand Down Expand Up @@ -63,5 +64,6 @@ def override_user():
return UserFactory.build(access_token=token)

app.dependency_overrides[get_current_user] = override_user
app.dependency_overrides[get_management_token] = lambda: "mock_token"
yield
app.dependency_overrides.clear()
13 changes: 11 additions & 2 deletions tests/datagen.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import random

from polyfactory.decorators import post_generated
from polyfactory.factories.pydantic_factory import ModelFactory

from auth0.schemas import Auth0UserResponse
from routers.bpa_register import BPARegistrationRequest
from schemas.galaxy import GalaxyRegistrationData
from schemas.service import Auth0User
from schemas.service import AppMetadata, Auth0User
from schemas.tokens import AccessTokenPayload
from schemas.user import User


class AccessTokenPayloadFactory(ModelFactory[AccessTokenPayload]): ...


class Auth0UserResponseFactory(ModelFactory[Auth0UserResponse]): ...
class Auth0UserResponseFactory(ModelFactory[Auth0UserResponse]):

@classmethod
def user_id(cls) -> str:
return "auth0|" + ''.join(random.choices('0123456789abcdef', k=24))


class UserFactory(ModelFactory[User]): ...
Expand Down Expand Up @@ -43,3 +49,6 @@ def get_default_organizations(cls) -> dict:
"cipps": False,
"ausarg": True,
}


class AppMetadataFactory(ModelFactory[AppMetadata]): ...
Loading