Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion docs/modules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Module Documentation
:undoc-members:
:show-inheritance:

.. automodule:: nexusml.api.ext
.. automodule:: nexusml.api.external.ext
:members:
:undoc-members:
:show-inheritance:
Expand Down
12 changes: 6 additions & 6 deletions nexusml/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@

from nexusml.api import routes
from nexusml.api.endpoints import ENDPOINT_SYS_CONFIG
from nexusml.api.ext import cache
from nexusml.api.ext import celery
from nexusml.api.ext import cors
from nexusml.api.ext import docs
from nexusml.api.ext import init_celery
from nexusml.api.ext import mail
from nexusml.api.external.ext import cache
from nexusml.api.external.ext import celery
from nexusml.api.external.ext import cors
from nexusml.api.external.ext import docs
from nexusml.api.external.ext import init_celery
from nexusml.api.external.ext import mail
from nexusml.api.utils import config
from nexusml.api.utils import decode_api_key
from nexusml.api.utils import DEFAULT_CONFIG
Expand Down
Empty file.
149 changes: 149 additions & 0 deletions nexusml/api/external/auth0.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import os
import re
import urllib.parse

from flask import g
import requests
from requests import Response
from werkzeug.exceptions import BadRequest

from nexusml.env import ENV_AUTH0_CLIENT_ID
from nexusml.env import ENV_AUTH0_CLIENT_SECRET
from nexusml.env import ENV_AUTH0_DOMAIN


class Auth0Manager:
"""Manages interactions with Auth0 API for retrieving, updating, and deleting user data.

This class provides functionalities to authenticate with Auth0, manage API tokens, and handle user data through
Auth0 Management API, including retrieving user information based on Auth0 ID or email, updating user details, and
deleting users from Auth0.
"""
auth0_domain_url: str = f'https://{os.environ[ENV_AUTH0_DOMAIN]}'
auth0_user_url: str = f'https://{os.environ[ENV_AUTH0_DOMAIN]}/api/v2/users'

def __init__(self, auth0_token: str = '') -> None:
"""
Initializes the Auth0Manager with an optional token for authorization.

Args:
auth0_token (str): The Auth0 API token for authorization. Defaults to an empty string.
"""
self._auth0_token: str = auth0_token
self.authorization_headers: dict = {'Authorization': 'Bearer ' + auth0_token}

if auth0_token == '':
self.update_authorization_header()

def update_authorization_header(self) -> None:
"""
Updates the authorization headers by retrieving a new Auth0 management API token.

This function is called when no token is provided during initialization, ensuring that the instance has valid
credentials to interact with Auth0 Management API.
"""
auth0_token: str = self._get_auth0_management_api_token()
self.authorization_headers = {'Authorization': 'Bearer ' + auth0_token}
self._auth0_token = auth0_token

def _get_auth0_management_api_token(self) -> str:
"""
Retrieves the Auth0 Management API token required for accessing the Auth0 database.

This function sends a POST request to the Auth0 token endpoint with the necessary credentials,
including client ID, client secret, and audience. The returned access token is used for making
further Auth0 Management API calls.

Returns:
str: The Auth0 Management API access token.
"""
access_token: str
payload: dict = {
'grant_type': 'client_credentials',
'client_id': os.environ[ENV_AUTH0_CLIENT_ID],
'client_secret': os.environ[ENV_AUTH0_CLIENT_SECRET],
'audience': f'{self.auth0_domain_url}/api/v2/'
}

headers: dict = {'Content-Type': 'application/json'}

response_data: Response = requests.post(f'{self.auth0_domain_url}/oauth/token', json=payload, headers=headers)
json_data: dict = response_data.json()
access_token = json_data['access_token']

return access_token

def get_auth0_user_data(self, auth0_id_or_email: str) -> dict:
"""
Matches an Auth0 ID or email to retrieve the associated user data.

This function checks if the provided identifier is an email or an Auth0 ID, constructs the appropriate URL,
and sends a GET request to retrieve the user account data. If the identifier is an email, it searches by email;
otherwise, it searches by Auth0 ID.

WARNING: If more than one user data is received in the response, only the first user data will be returned.

Args:
auth0_id_or_email (str): The Auth0 ID or email to match.

Returns:
dict: The matched user data.

Raises:
BadRequest: If no Auth0 user is associated with the provided identifier.
"""
auth0_user_data: dict
encoded_email_or_auth0_id = urllib.parse.quote(auth0_id_or_email)

regex_email = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b'
if re.fullmatch(regex_email, auth0_id_or_email):
url = f'{self.auth0_user_url}?q=email:{encoded_email_or_auth0_id}&search_engine=v3'
else:
url = f'{self.auth0_user_url}/{encoded_email_or_auth0_id}'

response: Response = requests.get(url, headers=self.authorization_headers)
if response.status_code != 200:
raise BadRequest(description=f'No Auth0 user associated with "{auth0_id_or_email}"')

auth0_user_data = response.json()
if auth0_user_data and isinstance(auth0_user_data, list):
auth0_user_data = auth0_user_data[0]

return auth0_user_data

def delete_auth0_user(self, auth0_id: str) -> None:
"""
Deletes an Auth0 user account based on the provided Auth0 ID.

This function retrieves an Auth0 management API token, constructs the URL for the user deletion endpoint,
and sends a DELETE request to remove the user.

Args:
auth0_id (str): The Auth0 ID of the user to delete.

Raises:
requests.HTTPError: If the DELETE request does not return a status code of 2XX.
"""
url = f'{self.auth0_user_url}/{auth0_id}'
response: Response = requests.delete(url, headers=self.authorization_headers)
response.raise_for_status()

def patch_auth0_user(self, updated_data: dict):
"""
Updates user data in Auth0 based on the provided information.

This function constructs the URL using the current user's Auth0 ID (retrieved from the `g` object),
and sends a PATCH request to update the user details with the provided data.

Args:
updated_data (dict): The new data to update the user with.

Raises:
requests.HTTPError: If the PATCH request does not return a status code of 2XX.
"""
url = f'{self.auth0_user_url}/{g.user_auth0_id}'
headers: dict = self.authorization_headers
headers['content-type'] = 'application/json'

response: Response = requests.patch(url, json=updated_data, headers=headers)
response.raise_for_status()
File renamed without changes.
2 changes: 1 addition & 1 deletion nexusml/api/jobs/periodic_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from sqlalchemy import or_ as sql_or
from sqlalchemy import text as sql_text

from nexusml.api.ext import mail
from nexusml.api.external.ext import mail
from nexusml.api.jobs.event_jobs import train
from nexusml.api.resources.organizations import User
from nexusml.api.resources.tasks import Task
Expand Down
2 changes: 1 addition & 1 deletion nexusml/api/make_celery.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from nexusml.api import create_app
from nexusml.api.ext import celery
from nexusml.api.external.ext import celery

flask_app = create_app()
celery_app = celery
2 changes: 1 addition & 1 deletion nexusml/api/resources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from sqlalchemy.exc import StatementError
from sqlalchemy.inspection import inspect

from nexusml.api.ext import cache
from nexusml.api.external.ext import cache
from nexusml.api.schemas.base import ResourceRequestSchema
from nexusml.api.schemas.base import ResourceResponseSchema
from nexusml.api.utils import API_DOMAIN
Expand Down
2 changes: 1 addition & 1 deletion nexusml/api/resources/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from nexusml.api.endpoints import ENDPOINT_ORG_FILE
from nexusml.api.endpoints import ENDPOINT_TASK_FILE
from nexusml.api.ext import cache
from nexusml.api.external.ext import cache
from nexusml.api.resources.base import PermissionDeniedError
from nexusml.api.resources.base import QuotaError
from nexusml.api.resources.base import Resource
Expand Down
37 changes: 16 additions & 21 deletions nexusml/api/resources/organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@
import os
from typing import Iterable, List, Optional, Union

from flask import g
import requests
from sqlalchemy.exc import OperationalError

from nexusml.api.endpoints import ENDPOINT_CLIENT
from nexusml.api.endpoints import ENDPOINT_COLLABORATOR
from nexusml.api.endpoints import ENDPOINT_ORGANIZATION
from nexusml.api.endpoints import ENDPOINT_ROLE
from nexusml.api.endpoints import ENDPOINT_USER
from nexusml.api.ext import cache
from nexusml.api.external.auth0 import Auth0Manager
from nexusml.api.external.ext import cache
from nexusml.api.resources.base import DuplicateResourceError
from nexusml.api.resources.base import InvalidDataError
from nexusml.api.resources.base import PermissionDeniedError
Expand All @@ -33,9 +32,6 @@
from nexusml.api.schemas.organizations import RoleResponseSchema
from nexusml.api.schemas.organizations import UserRequestSchema
from nexusml.api.schemas.organizations import UserResponseSchema
from nexusml.api.utils import delete_auth0_user
from nexusml.api.utils import get_auth0_management_api_token
from nexusml.api.utils import get_auth0_user_data
from nexusml.constants import ADMIN_ROLE
from nexusml.constants import API_NAME
from nexusml.constants import MAINTAINER_ROLE
Expand All @@ -61,7 +57,6 @@
from nexusml.enums import OrgFileUse
from nexusml.enums import ResourceAction
from nexusml.enums import ResourceType
from nexusml.env import ENV_AUTH0_DOMAIN
from nexusml.env import ENV_SUPPORT_EMAIL

_CONTACT_MSG = f'Please, contact {os.environ[ENV_SUPPORT_EMAIL]}'
Expand Down Expand Up @@ -102,6 +97,10 @@ def wrapper(*args, **kwargs):

class User(Resource):

def __init__(self):
super().__init__()
self._auth0_manager: Auth0Manager = Auth0Manager()

@classmethod
def db_model(cls):
return UserDB
Expand Down Expand Up @@ -198,11 +197,11 @@ def delete(self, notify_to: Iterable[UserDB] = None):

check_last_admin_deletion(user=self, user_roles=user_roles)

delete_auth0_user(auth0_id=self.db_object().auth0_id)
self._auth0_manager.delete_auth0_user(auth0_id=self.db_object().auth0_id)
super().delete(notify_to=notify_to)

@staticmethod
def download_auth0_user_data(auth0_id_or_email: str) -> dict:
def download_auth0_user_data(auth0_id_or_email: str, auth0_manager: Optional[Auth0Manager] = None) -> dict:
"""
Downloads user's data from Auth0.

Expand All @@ -215,16 +214,18 @@ def download_auth0_user_data(auth0_id_or_email: str) -> dict:

Args:
auth0_id_or_email (str): The user's `auth0_id` or email.
auth0_manager(Auth0Manager): Auth0Manager instance.

Returns:
dict: The account data.

Raises:
ResourceNotFoundError: If no account is found for the given UUID or email.
"""
mgmt_api_access_token: str = get_auth0_management_api_token()
auth0_user_data: dict = get_auth0_user_data(access_token=mgmt_api_access_token,
auth0_id_or_email=auth0_id_or_email)
if not auth0_manager:
auth0_manager: Auth0Manager = Auth0Manager()

auth0_user_data: dict = auth0_manager.get_auth0_user_data(auth0_id_or_email=auth0_id_or_email)

if not auth0_user_data:
raise ResourceNotFoundError(f'No Auth0 user found for {auth0_id_or_email}')
Expand All @@ -245,14 +246,7 @@ def put(self, data: dict, notify_to: Iterable[UserDB] = None) -> None:
fields_map: dict = {'first_name': 'given_name', 'last_name': 'family_name'}
updated_data: dict = {fields_map.get(key, key): value for key, value in data.items()}

mgmt_api_access_token = get_auth0_management_api_token()
url = f'https://{os.environ[ENV_AUTH0_DOMAIN]}/api/v2/users/{g.user_auth0_id}'
headers = {'Authorization': f'Bearer {mgmt_api_access_token}', 'content-type': 'application/json'}

response = requests.patch(url, json=updated_data, headers=headers)
if response.status_code < 200 or response.status_code >= 300:
# TODO: Replace this and other 'ifs' with response http error handler method 'raise_for_status'
raise requests.HTTPError('Auth0 patch request error')
self._auth0_manager.patch_auth0_user(updated_data=updated_data)

def dump(
self,
Expand All @@ -272,7 +266,8 @@ def dump(
db_user_data.pop('public_id')

auth0_id: str = self.db_object().auth0_id
auth0_user_data: dict = self.download_auth0_user_data(auth0_id_or_email=auth0_id)
auth0_user_data: dict = self.download_auth0_user_data(auth0_id_or_email=auth0_id,
auth0_manager=self._auth0_manager)
public_auth0_user_data: dict = {
'email': auth0_user_data['email'],
'first_name': auth0_user_data['first_name'],
Expand Down
Loading