From 4afab4400a0d085aeaf840750be454ce63f7cade Mon Sep 17 00:00:00 2001 From: ali-zipstack Date: Mon, 1 Jul 2024 14:45:01 +0530 Subject: [PATCH 1/2] version 2 of user/organization account module --- backend/account_v2/ReadMe.md | 26 + backend/account_v2/__init__.py | 0 backend/account_v2/admin.py | 5 + backend/account_v2/apps.py | 6 + .../account_v2/authentication_controller.py | 489 ++++++++++++++++++ backend/account_v2/authentication_helper.py | 121 +++++ .../authentication_plugin_registry.py | 96 ++++ backend/account_v2/authentication_service.py | 395 ++++++++++++++ backend/account_v2/constants.py | 89 ++++ backend/account_v2/custom_auth_middleware.py | 51 ++ backend/account_v2/custom_authentication.py | 13 + backend/account_v2/custom_cache.py | 12 + backend/account_v2/custom_exceptions.py | 60 +++ backend/account_v2/dto.py | 131 +++++ backend/account_v2/enums.py | 6 + backend/account_v2/exceptions.py | 31 ++ backend/account_v2/migrations/__init__.py | 0 backend/account_v2/models.py | 144 ++++++ backend/account_v2/organization.py | 45 ++ backend/account_v2/serializer.py | 117 +++++ backend/account_v2/subscription_loader.py | 77 +++ backend/account_v2/templates/index.html | 11 + backend/account_v2/templates/login.html | 134 +++++ backend/account_v2/tests.py | 1 + backend/account_v2/urls.py | 22 + backend/account_v2/user.py | 55 ++ backend/account_v2/views.py | 169 ++++++ 27 files changed, 2306 insertions(+) create mode 100644 backend/account_v2/ReadMe.md create mode 100644 backend/account_v2/__init__.py create mode 100644 backend/account_v2/admin.py create mode 100644 backend/account_v2/apps.py create mode 100644 backend/account_v2/authentication_controller.py create mode 100644 backend/account_v2/authentication_helper.py create mode 100644 backend/account_v2/authentication_plugin_registry.py create mode 100644 backend/account_v2/authentication_service.py create mode 100644 backend/account_v2/constants.py create mode 100644 backend/account_v2/custom_auth_middleware.py create mode 100644 backend/account_v2/custom_authentication.py create mode 100644 backend/account_v2/custom_cache.py create mode 100644 backend/account_v2/custom_exceptions.py create mode 100644 backend/account_v2/dto.py create mode 100644 backend/account_v2/enums.py create mode 100644 backend/account_v2/exceptions.py create mode 100644 backend/account_v2/migrations/__init__.py create mode 100644 backend/account_v2/models.py create mode 100644 backend/account_v2/organization.py create mode 100644 backend/account_v2/serializer.py create mode 100644 backend/account_v2/subscription_loader.py create mode 100644 backend/account_v2/templates/index.html create mode 100644 backend/account_v2/templates/login.html create mode 100644 backend/account_v2/tests.py create mode 100644 backend/account_v2/urls.py create mode 100644 backend/account_v2/user.py create mode 100644 backend/account_v2/views.py diff --git a/backend/account_v2/ReadMe.md b/backend/account_v2/ReadMe.md new file mode 100644 index 0000000000..35695cfb52 --- /dev/null +++ b/backend/account_v2/ReadMe.md @@ -0,0 +1,26 @@ +# Basic WorkFlow + +`We can Add Workflows Here` + +## Login + +### Step + +1. Login +2. Get Organizations +3. Set Organization +4. Use organizational APIs /unstract// + +## Switch organization + +1. Get Organizations +2. Set Organization +3. Use organizational APIs /unstract// + +## Get current user and Organization data + +- Use Get User Profile and Get Organization Info APIs + +## Signout + +1.signout APi diff --git a/backend/account_v2/__init__.py b/backend/account_v2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/account_v2/admin.py b/backend/account_v2/admin.py new file mode 100644 index 0000000000..e0b96cce89 --- /dev/null +++ b/backend/account_v2/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from .models import Organization, User + +admin.site.register([Organization, User]) diff --git a/backend/account_v2/apps.py b/backend/account_v2/apps.py new file mode 100644 index 0000000000..65343132cf --- /dev/null +++ b/backend/account_v2/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "account_v2" diff --git a/backend/account_v2/authentication_controller.py b/backend/account_v2/authentication_controller.py new file mode 100644 index 0000000000..0aec22294d --- /dev/null +++ b/backend/account_v2/authentication_controller.py @@ -0,0 +1,489 @@ +import logging +from typing import Any, Optional, Union + +from account_v2.authentication_helper import AuthenticationHelper +from account_v2.authentication_plugin_registry import AuthenticationPluginRegistry +from account_v2.authentication_service import AuthenticationService +from account_v2.constants import ( + AuthorizationErrorCode, + Common, + Cookie, + ErrorMessage, + OrganizationMemberModel, +) +from account_v2.custom_exceptions import ( + DuplicateData, + Forbidden, + MethodNotImplemented, + UserNotExistError, +) +from account_v2.dto import ( + MemberInvitation, + OrganizationData, + UserInfo, + UserInviteResponse, + UserRoleData, +) +from account_v2.exceptions import OrganizationNotExist +from account_v2.models import Organization, User +from account_v2.organization import OrganizationService +from account_v2.serializer import ( + GetOrganizationsResponseSerializer, + OrganizationSerializer, + SetOrganizationsResponseSerializer, +) +from account_v2.user import UserService +from django.conf import settings +from django.contrib.auth import logout as django_logout +from django.db.utils import IntegrityError +from django.middleware import csrf +from django.shortcuts import redirect +from rest_framework import status +from rest_framework.request import Request +from rest_framework.response import Response +from tenant_account_v2.models import OrganizationMember as OrganizationMember +from tenant_account_v2.organization_member_service import OrganizationMemberService +from utils.cache_service import CacheService +from utils.local_context import StateStore +from utils.user_context import UserContext +from utils.user_session import UserSessionUtils + +logger = logging.getLogger(__name__) + + +class AuthenticationController: + """Authentication Controller This controller class manages user + authentication processes.""" + + def __init__(self) -> None: + """This method initializes the controller by selecting the appropriate + authentication plugin based on availability.""" + self.authentication_helper = AuthenticationHelper() + if AuthenticationPluginRegistry.is_plugin_available(): + self.auth_service: AuthenticationService = ( + AuthenticationPluginRegistry.get_plugin() + ) + else: + self.auth_service = AuthenticationService() + + def user_login( + self, + request: Request, + ) -> Any: + return self.auth_service.user_login(request) + + def user_signup(self, request: Request) -> Any: + return self.auth_service.user_signup(request) + + def authorization_callback( + self, request: Request, backend: str = settings.DEFAULT_MODEL_BACKEND + ) -> Any: + """Handle authorization callback. + + This function processes the authorization callback from + an external service. + + Args: + request (Request): Request instance + backend (str, optional): backend used to use login. + Defaults: settings.DEFAULT_MODEL_BACKEND. + + Returns: + Any: Redirect response + """ + try: + return self.auth_service.handle_authorization_callback( + request=request, backend=backend + ) + except Exception as ex: + logger.error(f"Error while handling authorization callback: {ex}") + return redirect(f"{settings.ERROR_URL}") + + def user_organizations(self, request: Request) -> Any: + """List a user's organizations. + + Args: + user (User): User instance + z_code (str): _description_ + + Returns: + list[OrganizationData]: _description_ + """ + + try: + organizations = self.auth_service.user_organizations(request) + except Exception as ex: + # + self.user_logout(request) + + response = Response( + status=status.HTTP_412_PRECONDITION_FAILED, + ) + if hasattr(ex, "code") and ex.code in { + AuthorizationErrorCode.USF, + AuthorizationErrorCode.USR, + AuthorizationErrorCode.INE001, + AuthorizationErrorCode.INE002, + }: # type: ignore + response.data = ({"domain": ex.data.get("domain"), "code": ex.code},) + return response + # Return in case even if missed unknown exception in + # self.auth_service.user_organizations(request) + return response + + user: User = request.user + org_ids = {org.id for org in organizations} + + CacheService.set_user_organizations(user.user_id, list(org_ids)) + + serialized_organizations = GetOrganizationsResponseSerializer( + organizations, many=True + ).data + response = Response( + status=status.HTTP_200_OK, + data={ + "message": "success", + "organizations": serialized_organizations, + }, + ) + if Cookie.CSRFTOKEN not in request.COOKIES: + csrf_token = csrf.get_token(request) + response.set_cookie(Cookie.CSRFTOKEN, csrf_token) + + return response + + def set_user_organization(self, request: Request, organization_id: str) -> Response: + user: User = request.user + new_organization = False + organization_ids = CacheService.get_user_organizations(user.user_id) + if not organization_ids: + z_organizations: list[OrganizationData] = ( + self.auth_service.get_organizations_by_user_id(user.user_id) + ) + organization_ids = {org.id for org in z_organizations} + if organization_id and organization_id in organization_ids: + # Set organization in user context + UserContext.set_organization_identifier(organization_id) + organization = OrganizationService.get_organization_by_org_id( + organization_id + ) + if not organization: + try: + organization_data: OrganizationData = ( + self.auth_service.get_organization_by_org_id(organization_id) + ) + except ValueError: + raise OrganizationNotExist() + try: + organization = OrganizationService.create_organization( + organization_data.name, + organization_data.display_name, + organization_data.id, + ) + new_organization = True + except IntegrityError: + raise DuplicateData( + f"{ErrorMessage.ORGANIZATION_EXIST}, \ + {ErrorMessage.DUPLICATE_API}" + ) + self.create_tenant_user(organization=organization, user=user) + + if new_organization: + try: + self.auth_service.hubspot_signup_api(request=request) + except MethodNotImplemented: + logger.info("hubspot_signup_api not implemented") + + try: + self.auth_service.frictionless_onboarding( + organization=organization, user=user + ) + except MethodNotImplemented: + logger.info("frictionless_onboarding not implemented") + + self.authentication_helper.create_initial_platform_key( + user=user, organization=organization + ) + logger.info( + f"New organization created with Id {organization_id}", + ) + + user_info: Optional[UserInfo] = self.get_user_info(request) + serialized_user_info = SetOrganizationsResponseSerializer(user_info).data + organization_info = OrganizationSerializer(organization).data + response: Response = Response( + status=status.HTTP_200_OK, + data={ + "user": serialized_user_info, + "organization": organization_info, + f"{Common.LOG_EVENTS_ID}": StateStore.get(Common.LOG_EVENTS_ID), + }, + ) + current_organization_id = UserSessionUtils.get_organization_id(request) + if current_organization_id: + OrganizationMemberService.remove_user_membership_in_organization_cache( + user_id=user.user_id, + organization_id=current_organization_id, + ) + UserSessionUtils.set_organization_id(request, organization_id) + OrganizationMemberService.set_user_membership_in_organization_cache( + user_id=user.user_id, organization_id=organization_id + ) + return response + return Response(status=status.HTTP_403_FORBIDDEN) + + def get_user_info(self, request: Request) -> Optional[UserInfo]: + return self.auth_service.get_user_info(request) + + def is_admin_by_role(self, role: str) -> bool: + """Check the role is act as admin in the context of authentication + plugin. + + Args: + role (str): role + + Returns: + bool: _description_ + """ + return self.auth_service.is_admin_by_role(role=role) + + def get_organization_info(self, org_id: str) -> Optional[Organization]: + organization = OrganizationService.get_organization_by_org_id(org_id=org_id) + return organization + + def make_organization_and_add_member( + self, + user_id: str, + user_name: str, + organization_name: Optional[str] = None, + display_name: Optional[str] = None, + ) -> Optional[OrganizationData]: + return self.auth_service.make_organization_and_add_member( + user_id, user_name, organization_name, display_name + ) + + def make_user_organization_name(self) -> str: + return self.auth_service.make_user_organization_name() + + def make_user_organization_display_name(self, user_name: str) -> str: + return self.auth_service.make_user_organization_display_name(user_name) + + def user_logout(self, request: Request) -> Response: + response = self.auth_service.user_logout(request=request) + organization_id = UserSessionUtils.get_organization_id(request) + user_id = UserSessionUtils.get_user_id(request) + if organization_id: + OrganizationMemberService.remove_user_membership_in_organization_cache( + user_id=user_id, organization_id=organization_id + ) + django_logout(request) + return response + + def get_organization_members_by_org_id( + self, organization_id: Optional[str] = None + ) -> list[OrganizationMember]: + members: list[OrganizationMember] = OrganizationMemberService.get_members() + return members + + def get_organization_members_by_user(self, user: User) -> OrganizationMember: + """Get organization member by user. This method will return + organization member object for given user. + + Args: + user (User): UserEntity + + Returns: + OrganizationMember: OrganizationMemberEntity + """ + member: OrganizationMember = OrganizationMemberService.get_user_by_id( + id=user.id + ) + return member + + def get_user_roles(self) -> list[UserRoleData]: + return self.auth_service.get_roles() + + def get_user_invitations(self, organization_id: str) -> list[MemberInvitation]: + return self.auth_service.get_invitations(organization_id=organization_id) + + def delete_user_invitation(self, organization_id: str, invitation_id: str) -> bool: + return self.auth_service.delete_invitation( + organization_id=organization_id, invitation_id=invitation_id + ) + + def reset_user_password(self, user: User) -> Response: + return self.auth_service.reset_user_password(user) + + def invite_user( + self, + admin: User, + org_id: str, + user_list: list[dict[str, Union[str, None]]], + ) -> list[UserInviteResponse]: + """Invites users to join an organization. + + Args: + admin (User): Admin user initiating the invitation. + org_id (str): ID of the organization to which users are invited. + user_list (list[dict[str, Union[str, None]]]): + List of user details for invitation. + Returns: + list[UserInviteResponse]: List of responses for each + user invitation. + """ + admin_user = OrganizationMemberService.get_user_by_id(id=admin.id) + if not self.auth_service.is_organization_admin(admin_user): + raise Forbidden() + response = [] + for user_item in user_list: + email = user_item.get("email") + role = user_item.get("role") + if email: + user = OrganizationMemberService.get_user_by_email(email=email) + user_response = {} + user_response["email"] = email + status = False + message = "User is already part of current organization" + # Check if user is already part of current organization + if not user: + status = self.auth_service.invite_user( + admin_user, org_id, email, role=role + ) + message = "User invitation successful." + + response.append( + UserInviteResponse( + email=email, + status="success" if status else "failed", + message=message, + ) + ) + return response + + def remove_users_from_organization( + self, admin: User, organization_id: str, user_emails: list[str] + ) -> bool: + admin_user = OrganizationMemberService.get_user_by_id(id=admin.id) + user_ids = OrganizationMemberService.get_members_by_user_email( + user_emails=user_emails, + values_list_fields=[ + OrganizationMemberModel.USER_ID, + OrganizationMemberModel.ID, + ], + ) + user_ids_list: list[str] = [] + pk_list: list[str] = [] + for user in user_ids: + user_ids_list.append(user[0]) + pk_list.append(user[1]) + if len(user_ids_list) > 0: + is_removed = self.auth_service.remove_users_from_organization( + admin=admin_user, + organization_id=organization_id, + user_ids=user_ids_list, + ) + else: + is_removed = False + if is_removed: + AuthenticationHelper.remove_users_from_organization_by_pks(pk_list) + for user_id in user_ids_list: + OrganizationMemberService.remove_user_membership_in_organization_cache( + user_id, organization_id + ) + + return is_removed + + def add_user_role( + self, admin: User, org_id: str, email: str, role: str + ) -> Optional[str]: + admin_user = OrganizationMemberService.get_user_by_id(id=admin.id) + user = OrganizationMemberService.get_user_by_email(email=email) + if user: + current_roles = self.auth_service.add_organization_user_role( + admin_user, org_id, user.user.user_id, [role] + ) + if current_roles: + self.save_organization_user_role( + user_uid=user.user.user.id, role=current_roles[0] + ) + return current_roles[0] + else: + return None + + def remove_user_role( + self, admin: User, org_id: str, email: str, role: str + ) -> Optional[str]: + admin_user = OrganizationMemberService.get_user_by_id(id=admin.id) + organization_member = OrganizationMemberService.get_user_by_email(email=email) + if organization_member: + current_roles = self.auth_service.remove_organization_user_role( + admin_user, org_id, organization_member.user.user_id, [role] + ) + if current_roles: + self.save_organization_user_role( + user_uid=organization_member.user.id, + role=current_roles[0], + ) + return current_roles[0] + else: + return None + + def save_organization_user_role(self, user_uid: str, role: str) -> None: + organization_user = OrganizationMemberService.get_user_by_id(id=user_uid) + if organization_user: + # consider single role + organization_user.role = role + organization_user.save() + + def create_tenant_user(self, organization: Organization, user: User) -> None: + existing_tenant_user = OrganizationMemberService.get_user_by_id(id=user.id) + + if existing_tenant_user: + return None + + account_user = self.get_or_create_user(user=user) + if not account_user: + raise UserNotExistError() + + logger.info(f"Creating account for {user.email}") + user_roles = self.auth_service.get_organization_role_of_user( + user_id=account_user.user_id, + organization_id=organization.organization_id, + ) + user_role = user_roles[0] + try: + tenant_user: OrganizationMember = OrganizationMember( + user=user, + role=user_role, + is_login_onboarding_msg=False, + is_prompt_studio_onboarding_msg=False, + ) + tenant_user.save() + logger.info( + f"{tenant_user.user.email} added in to the organization " + f"{organization.organization_id}" + ) + except IntegrityError: + logger.warning(f"Account already exists for {user.email}") + + def get_or_create_user( + self, user: User + ) -> Optional[Union[User, OrganizationMember]]: + user_service = UserService() + if user.id: + account_user: Optional[User] = user_service.get_user_by_id(user.id) + if account_user: + return account_user + elif user.email: + account_user = user_service.get_user_by_email(email=user.email) + if account_user: + return account_user + if user.user_id: + user.save() + return user + elif user.email and user.user_id: + account_user = user_service.create_user( + email=user.email, user_id=user.user_id + ) + return account_user + return None diff --git a/backend/account_v2/authentication_helper.py b/backend/account_v2/authentication_helper.py new file mode 100644 index 0000000000..d2159a0f4d --- /dev/null +++ b/backend/account_v2/authentication_helper.py @@ -0,0 +1,121 @@ +import logging +from typing import Any + +from account_v2.dto import MemberData +from account_v2.models import Organization, User +from account_v2.user import UserService +from platform_settings_v2.platform_auth_service import PlatformAuthenticationService +from tenant_account_v2.organization_member_service import OrganizationMemberService + +logger = logging.getLogger(__name__) + + +class AuthenticationHelper: + def __init__(self) -> None: + pass + + def list_of_members_from_user_model( + self, model_data: list[Any] + ) -> list[MemberData]: + members: list[MemberData] = [] + for data in model_data: + user_id = data.user_id + email = data.email + name = data.username + + members.append(MemberData(user_id=user_id, email=email, name=name)) + + return members + + @staticmethod + def get_or_create_user_by_email(user_id: str, email: str) -> User: + """Get or create a user with the given email. + + If a user with the given email already exists, return that user. + Otherwise, create a new user with the given email and return it. + + Parameters: + user_id (str): The ID of the user. + email (str): The email of the user. + + Returns: + User: The user with the given email. + """ + user_service = UserService() + user = user_service.get_user_by_email(email) + if user and not user.user_id: + user = user_service.update_user(user, user_id) + if not user: + user = user_service.create_user(email, user_id) + return user + + def create_initial_platform_key( + self, user: User, organization: Organization + ) -> None: + """Create an initial platform key for the given user and organization. + + This method generates a new platform key with the specified parameters + and saves it to the database. The generated key is set as active and + assigned the name "Key #1". The key is associated with the provided + user and organization. + + Parameters: + user (User): The user for whom the platform key is being created. + organization (Organization): + The organization to which the platform key belongs. + + Raises: + Exception: If an error occurs while generating the platform key. + + Returns: + None + """ + try: + PlatformAuthenticationService.generate_platform_key( + is_active=True, + key_name="Key #1", + user=user, + organization=organization, + ) + except Exception: + logger.error( + "Failed to create default platform key for " + f"organization {organization.organization_id}" + ) + + @staticmethod + def remove_users_from_organization_by_pks( + user_pks: list[str], + ) -> None: + """Remove users from an organization by their primary keys. + + Parameters: + user_pks (list[str]): The primary keys of the users to remove. + """ + # removing user from organization + OrganizationMemberService.remove_users_by_user_pks(user_pks) + # removing user m2m relations , while removing user + for user_pk in user_pks: + User.objects.get(pk=user_pk).shared_exported_tools.clear() + User.objects.get(pk=user_pk).shared_custom_tool.clear() + User.objects.get(pk=user_pk).shared_adapters.clear() + + @staticmethod + def remove_user_from_organization_by_user_id( + user_id: str, organization_id: str + ) -> None: + """Remove users from an organization by their user_id. + + Parameters: + user_id (str): The user_id of the users to remove. + """ + # removing user from organization + OrganizationMemberService.remove_user_by_user_id(user_id) + # removing user m2m relations , while removing user + User.objects.get(user_id=user_id).shared_exported_tools.clear() + User.objects.get(user_id=user_id).shared_custom_tool.clear() + User.objects.get(user_id=user_id).shared_adapters.clear() + # removing user from organization cache + OrganizationMemberService.remove_user_membership_in_organization_cache( + user_id=user_id, organization_id=organization_id + ) diff --git a/backend/account_v2/authentication_plugin_registry.py b/backend/account_v2/authentication_plugin_registry.py new file mode 100644 index 0000000000..4521af1cb5 --- /dev/null +++ b/backend/account_v2/authentication_plugin_registry.py @@ -0,0 +1,96 @@ +import logging +import os +from importlib import import_module +from typing import Any + +from account_v2.constants import PluginConfig +from django.apps import apps + +logger = logging.getLogger(__name__) + + +def _load_plugins() -> dict[str, dict[str, Any]]: + """Iterating through the Authentication plugins and register their + metadata.""" + auth_app = apps.get_app_config(PluginConfig.PLUGINS_APP) + auth_package_path = auth_app.module.__package__ + auth_dir = os.path.join(auth_app.path, PluginConfig.AUTH_PLUGIN_DIR) + auth_package_path = f"{auth_package_path}.{PluginConfig.AUTH_PLUGIN_DIR}" + auth_modules = {} + + for item in os.listdir(auth_dir): + # Loads a plugin only if name starts with `auth`. + if not item.startswith(PluginConfig.AUTH_MODULE_PREFIX): + continue + # Loads a plugin if it is in a directory. + if os.path.isdir(os.path.join(auth_dir, item)): + auth_module_name = item + # Loads a plugin if it is a shared library. + # Module name is extracted from shared library name. + # `auth.platform_architecture.so` will be file name and + # `auth` will be the module name. + elif item.endswith(".so"): + auth_module_name = item.split(".")[0] + else: + continue + try: + full_module_path = f"{auth_package_path}.{auth_module_name}" + module = import_module(full_module_path) + metadata = getattr(module, PluginConfig.AUTH_METADATA, {}) + if metadata.get(PluginConfig.METADATA_IS_ACTIVE, False): + auth_modules[auth_module_name] = { + PluginConfig.AUTH_MODULE: module, + PluginConfig.AUTH_METADATA: module.metadata, + } + logger.info( + "Loaded auth plugin: %s, is_active: %s", + module.metadata["name"], + module.metadata["is_active"], + ) + else: + logger.warning( + "Metadata is not active for %s authentication module.", + auth_module_name, + ) + except ModuleNotFoundError as exception: + logger.error( + "Error while importing authentication module : %s", + exception, + ) + + if len(auth_modules) > 1: + raise ValueError( + "Multiple authentication modules found." + "Only one authentication method is allowed." + ) + elif len(auth_modules) == 0: + logger.warning( + "No authentication modules found." + "Application will start without authentication module" + ) + return auth_modules + + +class AuthenticationPluginRegistry: + auth_modules: dict[str, dict[str, Any]] = _load_plugins() + + @classmethod + def is_plugin_available(cls) -> bool: + """Check if any authentication plugin is available. + + Returns: + bool: True if a plugin is available, False otherwise. + """ + return len(cls.auth_modules) > 0 + + @classmethod + def get_plugin(cls) -> Any: + """Get the selected authentication plugin. + + Returns: + AuthenticationService: Selected authentication plugin instance. + """ + chosen_auth_module = next(iter(cls.auth_modules.values())) + chosen_metadata = chosen_auth_module[PluginConfig.AUTH_METADATA] + service_class_name = chosen_metadata[PluginConfig.METADATA_SERVICE_CLASS] + return service_class_name() diff --git a/backend/account_v2/authentication_service.py b/backend/account_v2/authentication_service.py new file mode 100644 index 0000000000..273ce4fdbc --- /dev/null +++ b/backend/account_v2/authentication_service.py @@ -0,0 +1,395 @@ +import logging +import uuid +from typing import Any, Optional + +from account_v2.authentication_helper import AuthenticationHelper +from account_v2.constants import DefaultOrg, ErrorMessage, UserLoginTemplate +from account_v2.custom_exceptions import Forbidden, MethodNotImplemented +from account_v2.dto import ( + CallbackData, + MemberData, + MemberInvitation, + OrganizationData, + ResetUserPasswordDto, + UserInfo, + UserRoleData, +) +from account_v2.enums import UserRole +from account_v2.models import Organization, User +from account_v2.organization import OrganizationService +from account_v2.serializer import LoginRequestSerializer +from django.conf import settings +from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.hashers import make_password +from django.http import HttpRequest +from django.shortcuts import redirect, render +from rest_framework.request import Request +from rest_framework.response import Response +from tenant_account_v2.models import OrganizationMember as OrganizationMember +from tenant_account_v2.organization_member_service import OrganizationMemberService + +Logger = logging.getLogger(__name__) + + +class AuthenticationService: + def __init__(self) -> None: + self.authentication_helper = AuthenticationHelper() + self.default_organization: Organization = self.user_organization() + + def user_login(self, request: Request) -> Any: + """Authenticate and log in a user. + + Args: + request (Request): The HTTP request object. + + Returns: + Any: The response object. + + Raises: + ValueError: If there is an error in the login credentials. + """ + if request.method == "GET": + return self.render_login_page(request) + try: + validated_data = self.validate_login_credentials(request) + username = validated_data.get("username") + password = validated_data.get("password") + except ValueError as e: + return render( + request, + UserLoginTemplate.TEMPLATE, + {UserLoginTemplate.ERROR_PLACE_HOLDER: str(e)}, + ) + if self.authenticate_and_login(request, username, password): + return redirect(settings.WEB_APP_ORIGIN_URL) + + return self.render_login_page_with_error(request, ErrorMessage.USER_LOGIN_ERROR) + + def is_authenticated(self, request: HttpRequest) -> bool: + """Check if the user is authenticated. + + Args: + request (Request): The HTTP request object. + + Returns: + bool: True if the user is authenticated, False otherwise. + """ + return request.user.is_authenticated + + def authenticate_and_login( + self, request: Request, username: str, password: str + ) -> bool: + """Authenticate and log in a user. + + Args: + request (Request): The HTTP request object. + username (str): The username of the user. + password (str): The password of the user. + + Returns: + bool: True if the user is successfully authenticated and logged in, + False otherwise. + """ + user = authenticate(request, username=username, password=password) + if user: + # To avoid conflicts with django superuser + if user.is_superuser: + return False + login(request, user) + return True + # Attempt to initiate default user and authenticate again + if self.set_default_user(username, password): + user = authenticate(request, username=username, password=password) + if user: + login(request, user) + return True + return False + + def render_login_page(self, request: Request) -> Any: + return render(request, UserLoginTemplate.TEMPLATE) + + def render_login_page_with_error(self, request: Request, error_message: str) -> Any: + return render( + request, + UserLoginTemplate.TEMPLATE, + {UserLoginTemplate.ERROR_PLACE_HOLDER: error_message}, + ) + + def validate_login_credentials(self, request: Request) -> Any: + """Validate the login credentials. + + Args: + request (Request): The HTTP request object. + + Returns: + dict: The validated login credentials. + + Raises: + ValueError: If the login credentials are invalid. + """ + serializer = LoginRequestSerializer(data=request.POST) + if not serializer.is_valid(): + error_messages = { + field: errors[0] for field, errors in serializer.errors.items() + } + first_error_message = list(error_messages.values())[0] + raise ValueError(first_error_message) + return serializer.validated_data + + def user_signup(self, request: HttpRequest) -> Any: + raise MethodNotImplemented() + + def is_admin_by_role(self, role: str) -> bool: + """Check the role with actual admin Role. + + Args: + role (str): input string + + Returns: + bool: _description_ + """ + try: + return UserRole(role.lower()) == UserRole.ADMIN + except ValueError: + return False + + def get_callback_data(self, request: Request) -> CallbackData: + return CallbackData( + user_id=request.user.user_id, + email=request.user.email, + token="", + ) + + def user_organization(self) -> Organization: + return Organization( + name=DefaultOrg.ORGANIZATION_NAME, + display_name=DefaultOrg.ORGANIZATION_NAME, + organization_id=DefaultOrg.ORGANIZATION_NAME, + schema_name=DefaultOrg.ORGANIZATION_NAME, + ) + + def handle_invited_user_while_callback( + self, request: Request, user: User + ) -> MemberData: + member_data: MemberData = MemberData( + user_id=user.user_id, + organization_id=self.default_organization.organization_id, + role=[UserRole.ADMIN.value], + ) + + return member_data + + def handle_authorization_callback(self, request: Request, backend: str) -> Response: + raise MethodNotImplemented() + + def add_to_organization( + self, + request: Request, + user: User, + data: Optional[dict[str, Any]] = None, + ) -> MemberData: + member_data: MemberData = MemberData( + user_id=user.user_id, + organization_id=self.default_organization.organization_id, + ) + + return member_data + + def remove_users_from_organization( + self, + admin: OrganizationMember, + organization_id: str, + user_ids: list[str], + ) -> bool: + raise MethodNotImplemented() + + def user_organizations(self, request: Request) -> list[OrganizationData]: + organizationData: OrganizationData = OrganizationData( + id=self.default_organization.organization_id, + display_name=self.default_organization.display_name, + name=self.default_organization.name, + ) + return [organizationData] + + def get_organizations_by_user_id(self, id: str) -> list[OrganizationData]: + organizationData: OrganizationData = OrganizationData( + id=self.default_organization.organization_id, + display_name=self.default_organization.display_name, + name=self.default_organization.name, + ) + return [organizationData] + + def get_organization_role_of_user( + self, user_id: str, organization_id: str + ) -> list[str]: + return [UserRole.ADMIN.value] + + def is_organization_admin(self, member: OrganizationMember) -> bool: + """Check if the organization member has administrative privileges. + + Args: + member (OrganizationMember): The organization member to check. + + Returns: + bool: True if the user has administrative privileges, + False otherwise. + """ + try: + return UserRole(member.role) == UserRole.ADMIN + except ValueError: + return False + + def check_user_organization_association(self, user_email: str) -> None: + """Check if the user is already associated with any organizations. + + Raises: + - UserAlreadyAssociatedException: + If the user is already associated with organizations. + """ + return None + + def get_roles(self) -> list[UserRoleData]: + return [ + UserRoleData(name=UserRole.ADMIN.value), + UserRoleData(name=UserRole.USER.value), + ] + + def get_invitations(self, organization_id: str) -> list[MemberInvitation]: + raise MethodNotImplemented() + + def frictionless_onboarding(self, organization: Organization, user: User) -> None: + raise MethodNotImplemented() + + def hubspot_signup_api(self, request: Request) -> None: + raise MethodNotImplemented() + + def delete_invitation(self, organization_id: str, invitation_id: str) -> bool: + raise MethodNotImplemented() + + def add_organization_user_role( + self, + admin: User, + organization_id: str, + user_id: str, + role_ids: list[str], + ) -> list[str]: + if admin.role == UserRole.ADMIN.value: + return role_ids + raise Forbidden + + def remove_organization_user_role( + self, + admin: User, + organization_id: str, + user_id: str, + role_ids: list[str], + ) -> list[str]: + if admin.role == UserRole.ADMIN.value: + return role_ids + raise Forbidden + + def get_organization_by_org_id(self, id: str) -> OrganizationData: + organizationData: OrganizationData = OrganizationData( + id=DefaultOrg.ORGANIZATION_NAME, + display_name=DefaultOrg.ORGANIZATION_NAME, + name=DefaultOrg.ORGANIZATION_NAME, + ) + return organizationData + + def set_default_user(self, username: str, password: str) -> bool: + """Set the default user for authentication. + + This method creates a default user with the provided username and + password if the username and password match the default values defined + in the 'DefaultOrg' class. The default user is saved in the database. + + Args: + username (str): The username of the default user. + password (str): The password of the default user. + + Returns: + bool: True if the default user is successfully created and saved, + False otherwise. + """ + if ( + username != DefaultOrg.MOCK_USER + or password != DefaultOrg.MOCK_USER_PASSWORD + ): + return False + + user, created = User.objects.get_or_create(username=DefaultOrg.MOCK_USER) + if created: + user.password = make_password(DefaultOrg.MOCK_USER_PASSWORD) + else: + user.user_id = DefaultOrg.MOCK_USER_ID + user.email = DefaultOrg.MOCK_USER_EMAIL + user.password = make_password(DefaultOrg.MOCK_USER_PASSWORD) + user.save() + return True + + def get_user_info(self, request: Request) -> Optional[UserInfo]: + user: User = request.user + if user: + return UserInfo( + id=user.id, + user_id=user.user_id, + name=user.username, + display_name=user.username, + email=user.email, + ) + else: + return None + + def get_organization_info(self, org_id: str) -> Optional[Organization]: + return OrganizationService.get_organization_by_org_id(org_id=org_id) + + def make_organization_and_add_member( + self, + user_id: str, + user_name: str, + organization_name: Optional[str] = None, + display_name: Optional[str] = None, + ) -> Optional[OrganizationData]: + organization: OrganizationData = OrganizationData( + id=str(uuid.uuid4()), + display_name=DefaultOrg.MOCK_ORG, + name=DefaultOrg.MOCK_ORG, + ) + return organization + + def make_user_organization_name(self) -> str: + return str(uuid.uuid4()) + + def make_user_organization_display_name(self, user_name: str) -> str: + name = f"{user_name}'s" if user_name else "Your" + return f"{name} organization" + + def user_logout(self, request: HttpRequest) -> Response: + """Log out the user. + + Args: + request (HttpRequest): The HTTP request object. + + Returns: + Response: The redirect response to the web app origin URL. + """ + logout(request) + return redirect(settings.WEB_APP_ORIGIN_URL) + + def get_organization_members_by_org_id( + self, organization_id: str + ) -> list[MemberData]: + users: list[OrganizationMember] = OrganizationMemberService.get_members() + return self.authentication_helper.list_of_members_from_user_model(users) + + def reset_user_password(self, user: User) -> ResetUserPasswordDto: + raise MethodNotImplemented() + + def invite_user( + self, + admin: OrganizationMember, + org_id: str, + email: str, + role: Optional[str] = None, + ) -> bool: + raise MethodNotImplemented() diff --git a/backend/account_v2/constants.py b/backend/account_v2/constants.py new file mode 100644 index 0000000000..15c1137903 --- /dev/null +++ b/backend/account_v2/constants.py @@ -0,0 +1,89 @@ +from django.conf import settings + + +class LoginConstant: + INVITATION = "invitation" + ORGANIZATION = "organization" + ORGANIZATION_NAME = "organization_name" + + +class Common: + NEXT_URL_VARIABLE = "next" + PUBLIC_SCHEMA_NAME = "public" + ID = "id" + USER_ID = "user_id" + USER_EMAIL = "email" + USER_EMAILS = "emails" + USER_IDS = "user_ids" + USER_ROLE = "role" + MAX_EMAIL_IN_REQUEST = 10 + LOG_EVENTS_ID = "log_events_id" + + +class UserModel: + USER_ID = "user_id" + ID = "id" + + +class OrganizationMemberModel: + USER_ID = "user__user_id" + ID = "user__id" + + +class Cookie: + ORG_ID = "org_id" + Z_CODE = "z_code" + CSRFTOKEN = "csrftoken" + + +class ErrorMessage: + ORGANIZATION_EXIST = "Organization already exists" + DUPLICATE_API = "It appears that a duplicate call may have been made." + USER_LOGIN_ERROR = "Invalid username or password. Please try again." + + +class DefaultOrg: + ORGANIZATION_NAME = "mock_org" + MOCK_ORG = "mock_org" + MOCK_USER = settings.DEFAULT_AUTH_USERNAME + MOCK_USER_ID = "mock_user_id" + MOCK_USER_EMAIL = "email@mock.com" + MOCK_USER_PASSWORD = settings.DEFAULT_AUTH_PASSWORD + + +class UserLoginTemplate: + TEMPLATE = "login.html" + ERROR_PLACE_HOLDER = "error_message" + + +class PluginConfig: + PLUGINS_APP = "plugins" + AUTH_MODULE_PREFIX = "auth" + AUTH_PLUGIN_DIR = "authentication" + AUTH_MODULE = "module" + AUTH_METADATA = "metadata" + METADATA_SERVICE_CLASS = "service_class" + METADATA_IS_ACTIVE = "is_active" + + +class AuthorizationErrorCode: + """Error codes + IDM: INVITATION DENIED MESSAGE (Unauthorized invitation) + INF: INVITATION NOT FOUND (Invitation is either invalid or has expired) + UMM: USER MEMBERSHIP MISCONDUCT + USF: USER FOUND (User Account Already Exists for Organization) + INE001: INVALID EMAIL Exception code when an invalid email address is used + like disposable. + INE002: INVALID EMAIL Exception code when an invalid email address format. + + Error code reference : + frontend/src/components/error/GenericError/GenericError.jsx. + """ + + IDM = "IDM" + UMM = "UMM" + INF = "INF" + USF = "USF" + USR = "USR" + INE001 = "INE001" + INE002 = "INE002" diff --git a/backend/account_v2/custom_auth_middleware.py b/backend/account_v2/custom_auth_middleware.py new file mode 100644 index 0000000000..db49d7352c --- /dev/null +++ b/backend/account_v2/custom_auth_middleware.py @@ -0,0 +1,51 @@ +from account_v2.authentication_plugin_registry import AuthenticationPluginRegistry +from account_v2.authentication_service import AuthenticationService +from account_v2.constants import Common +from django.conf import settings +from django.http import HttpRequest, HttpResponse, JsonResponse +from utils.constants import Account +from utils.local_context import StateStore +from utils.user_session import UserSessionUtils + +from backend.constants import RequestHeader + + +class CustomAuthMiddleware: + def __init__(self, get_response: HttpResponse): + self.get_response = get_response + # One-time configuration and initialization. + + def __call__(self, request: HttpRequest) -> HttpResponse: + # Returns result without authenticated if added in whitelisted paths + if any(request.path.startswith(path) for path in settings.WHITELISTED_PATHS): + return self.get_response(request) + + # Authenticating With API_KEY + x_api_key = request.headers.get(RequestHeader.X_API_KEY) + if ( + settings.INTERNAL_SERVICE_API_KEY + and x_api_key == settings.INTERNAL_SERVICE_API_KEY + ): # Should API Key be in settings or just env alone? + return self.get_response(request) + + if AuthenticationPluginRegistry.is_plugin_available(): + auth_service: AuthenticationService = ( + AuthenticationPluginRegistry.get_plugin() + ) + else: + auth_service = AuthenticationService() + + is_authenticated = auth_service.is_authenticated(request) + + if is_authenticated: + StateStore.set(Common.LOG_EVENTS_ID, request.session.session_key) + StateStore.set( + Account.ORGANIZATION_ID, + UserSessionUtils.get_organization_id(request=request), + ) + response = self.get_response(request) + StateStore.clear(Account.ORGANIZATION_ID) + StateStore.clear(Common.LOG_EVENTS_ID) + + return response + return JsonResponse({"message": "Unauthorized"}, status=401) diff --git a/backend/account_v2/custom_authentication.py b/backend/account_v2/custom_authentication.py new file mode 100644 index 0000000000..1f7cdcb6e3 --- /dev/null +++ b/backend/account_v2/custom_authentication.py @@ -0,0 +1,13 @@ +from typing import Any + +from django.http import HttpRequest +from rest_framework.exceptions import AuthenticationFailed + + +def api_login_required(view_func: Any) -> Any: + def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any: + if request.user and request.session and "user" in request.session: + return view_func(request, *args, **kwargs) + raise AuthenticationFailed("Unauthorized") + + return wrapper diff --git a/backend/account_v2/custom_cache.py b/backend/account_v2/custom_cache.py new file mode 100644 index 0000000000..182f980f5b --- /dev/null +++ b/backend/account_v2/custom_cache.py @@ -0,0 +1,12 @@ +from django_redis import get_redis_connection + + +class CustomCache: + def __init__(self) -> None: + self.cache = get_redis_connection("default") + + def rpush(self, key: str, value: str) -> None: + self.cache.rpush(key, value) + + def lrem(self, key: str, value: str) -> None: + self.cache.lrem(key, value) diff --git a/backend/account_v2/custom_exceptions.py b/backend/account_v2/custom_exceptions.py new file mode 100644 index 0000000000..bec24e16c1 --- /dev/null +++ b/backend/account_v2/custom_exceptions.py @@ -0,0 +1,60 @@ +from typing import Optional + +from rest_framework.exceptions import APIException + + +class ConflictError(Exception): + def __init__(self, message: str) -> None: + self.message = message + super().__init__(self.message) + + +class MethodNotImplemented(APIException): + status_code = 501 + default_detail = "Method Not Implemented" + + +class DuplicateData(APIException): + status_code = 400 + default_detail = "Duplicate Data" + + def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): + if detail is not None: + self.detail = detail + if code is not None: + self.code = code + super().__init__(detail, code) + + +class TableNotExistError(APIException): + status_code = 400 + default_detail = "Unknown Table" + + def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): + if detail is not None: + self.detail = detail + if code is not None: + self.code = code + super().__init__() + + +class UserNotExistError(APIException): + status_code = 400 + default_detail = "Unknown User" + + def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): + if detail is not None: + self.detail = detail + if code is not None: + self.code = code + super().__init__() + + +class Forbidden(APIException): + status_code = 403 + default_detail = "Do not have permission to perform this action." + + +class UserAlreadyAssociatedException(APIException): + status_code = 400 + default_detail = "User is already associated with one organization." diff --git a/backend/account_v2/dto.py b/backend/account_v2/dto.py new file mode 100644 index 0000000000..1554901fb6 --- /dev/null +++ b/backend/account_v2/dto.py @@ -0,0 +1,131 @@ +from dataclasses import dataclass +from typing import Any, Optional + + +@dataclass +class MemberData: + user_id: str + email: Optional[str] = None + name: Optional[str] = None + picture: Optional[str] = None + role: Optional[list[str]] = None + organization_id: Optional[str] = None + + +@dataclass +class OrganizationData: + id: str + display_name: str + name: str + + +@dataclass +class CallbackData: + user_id: str + email: str + token: Any + + +@dataclass +class OrganizationSignupRequestBody: + name: str + display_name: str + organization_id: str + + +@dataclass +class OrganizationSignupResponse: + name: str + display_name: str + organization_id: str + created_at: str + + +@dataclass +class UserInfo: + email: str + user_id: str + id: Optional[str] = None + name: Optional[str] = None + display_name: Optional[str] = None + family_name: Optional[str] = None + picture: Optional[str] = None + + +@dataclass +class UserSessionInfo: + id: str + user_id: str + email: str + organization_id: str + user: UserInfo + + @staticmethod + def from_dict(data: dict[str, Any]) -> "UserSessionInfo": + return UserSessionInfo( + id=data["id"], + user_id=data["user_id"], + email=data["email"], + organization_id=data["organization_id"], + ) + + def to_dict(self) -> Any: + return { + "id": self.id, + "user_id": self.user_id, + "email": self.email, + "organization_id": self.organization_id, + } + + +@dataclass +class GetUserReposne: + user: UserInfo + organizations: list[OrganizationData] + + +@dataclass +class ResetUserPasswordDto: + status: bool + message: str + + +@dataclass +class UserInviteResponse: + email: str + status: str + message: Optional[str] = None + + +@dataclass +class UserRoleData: + name: str + id: Optional[str] = None + description: Optional[str] = None + + +@dataclass +class MemberInvitation: + """Represents an invitation to join an organization. + + Attributes: + id (str): The unique identifier for the invitation. + email (str): The user email. + roles (List[str]): The roles assigned to the invitee. + created_at (Optional[str]): The timestamp when the invitation + was created. + expires_at (Optional[str]): The timestamp when the invitation expires. + """ + + id: str + email: str + roles: list[str] + created_at: Optional[str] = None + expires_at: Optional[str] = None + + +@dataclass +class UserOrganizationRole: + user_id: str + role: UserRoleData + organization_id: str diff --git a/backend/account_v2/enums.py b/backend/account_v2/enums.py new file mode 100644 index 0000000000..d8209ec2d3 --- /dev/null +++ b/backend/account_v2/enums.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class UserRole(Enum): + USER = "user" + ADMIN = "admin" diff --git a/backend/account_v2/exceptions.py b/backend/account_v2/exceptions.py new file mode 100644 index 0000000000..2f5d34a84d --- /dev/null +++ b/backend/account_v2/exceptions.py @@ -0,0 +1,31 @@ +from rest_framework.exceptions import APIException + + +class UserIdNotExist(APIException): + status_code = 404 + default_detail = "User ID does not exist" + + +class UserAlreadyExistInOrganization(APIException): + status_code = 403 + default_detail = "User allready exist in the organization" + + +class OrganizationNotExist(APIException): + status_code = 404 + default_detail = "Organization does not exist" + + +class UnknownException(APIException): + status_code = 500 + default_detail = "An unexpected error occurred" + + +class BadRequestException(APIException): + status_code = 400 + default_detail = "Bad Request" + + +class Unauthorized(APIException): + status_code = 401 + default_detail = "Unauthorized" diff --git a/backend/account_v2/migrations/__init__.py b/backend/account_v2/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/account_v2/models.py b/backend/account_v2/models.py new file mode 100644 index 0000000000..61428bda01 --- /dev/null +++ b/backend/account_v2/models.py @@ -0,0 +1,144 @@ +import uuid + +from django.contrib.auth.models import AbstractUser, Group, Permission +from django.db import models + +from backend.constants import FieldLengthConstants as FieldLength + +NAME_SIZE = 64 +KEY_SIZE = 64 + + +class Organization(models.Model): + """Stores data related to an organization. + + The fields created_by and modified_by is updated after a + :model:`account.User` is created. + """ + + name = models.CharField(max_length=NAME_SIZE) + display_name = models.CharField(max_length=NAME_SIZE) + organization_id = models.CharField( + max_length=FieldLength.ORG_NAME_SIZE, unique=True + ) + created_by = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + related_name="orgs_created", + null=True, + blank=True, + ) + modified_by = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + related_name="orgs_modified", + null=True, + blank=True, + ) + modified_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField(auto_now=True) + allowed_token_limit = models.IntegerField( + default=-1, + db_comment="token limit set in case of frition less onbaoarded org", + ) + + class Meta: + verbose_name = "Organization" + verbose_name_plural = "Organizations" + db_table = "organization_v2" + + +class User(AbstractUser): + """Stores data related to a user belonging to any organization. + + Every org, user is assumed to be unique. + """ + + # Third Party Authentication User ID + user_id = models.CharField() + project_storage_created = models.BooleanField(default=False) + created_by = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + related_name="users_created", + null=True, + blank=True, + ) + modified_by = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + related_name="users_modified", + null=True, + blank=True, + ) + modified_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField(auto_now_add=True) + + # Specify a unique related_name for the groups field + groups = models.ManyToManyField( + Group, + related_name="users", + related_query_name="user", + blank=True, + ) + + # Specify a unique related_name for the user_permissions field + user_permissions = models.ManyToManyField( + Permission, + related_name="users", + related_query_name="user", + blank=True, + ) + + def __str__(self): # type: ignore + return f"User({self.id}, email: {self.email}, userId: {self.user_id})" + + class Meta: + verbose_name = "User" + verbose_name_plural = "Users" + db_table = "user_v2" + + +class PlatformKey(models.Model): + """Model to hold details of Platform keys. + + Only users with admin role are allowed to perform any operation + related keys. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + key = models.UUIDField(default=uuid.uuid4) + key_name = models.CharField(max_length=KEY_SIZE, null=False, blank=True, default="") + is_active = models.BooleanField(default=False) + organization = models.ForeignKey( + "Organization", + on_delete=models.SET_NULL, + related_name="platform_keys", + null=True, + blank=True, + ) + created_by = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + related_name="platform_keys_created", + null=True, + blank=True, + ) + modified_by = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + related_name="platform_keys_modified", + null=True, + blank=True, + ) + + class Meta: + verbose_name = "Platform Key" + verbose_name_plural = "Platform Keys" + db_table = "platform_key_v2" + constraints = [ + models.UniqueConstraint( + fields=["key_name", "organization"], + name="unique_key_name_organization", + ), + ] diff --git a/backend/account_v2/organization.py b/backend/account_v2/organization.py new file mode 100644 index 0000000000..72ef69200d --- /dev/null +++ b/backend/account_v2/organization.py @@ -0,0 +1,45 @@ +import logging +from typing import Optional + +from account_v2.models import Organization +from account_v2.subscription_loader import SubscriptionConfig, load_plugins +from django.db import IntegrityError + +Logger = logging.getLogger(__name__) + +subscription_loader = load_plugins() + + +class OrganizationService: + def __init__(self): # type: ignore + pass + + @staticmethod + def get_organization_by_org_id(org_id: str) -> Optional[Organization]: + try: + return Organization.objects.get(organization_id=org_id) # type: ignore + except Organization.DoesNotExist: + return None + + @staticmethod + def create_organization( + name: str, display_name: str, organization_id: str + ) -> Organization: + try: + organization: Organization = Organization( + name=name, + display_name=display_name, + organization_id=organization_id, + ) + organization.save() + + for subscription_plugin in subscription_loader: + cls = subscription_plugin[SubscriptionConfig.METADATA][ + SubscriptionConfig.METADATA_SERVICE_CLASS + ] + cls.add(organization_id=organization_id) + + except IntegrityError as error: + Logger.info(f"[Duplicate Id] Failed to create Organization Error: {error}") + raise error + return organization diff --git a/backend/account_v2/serializer.py b/backend/account_v2/serializer.py new file mode 100644 index 0000000000..87c531a4a9 --- /dev/null +++ b/backend/account_v2/serializer.py @@ -0,0 +1,117 @@ +import re +from typing import Optional + +from account_v2.models import Organization, User +from rest_framework import serializers + + +class OrganizationSignupSerializer(serializers.Serializer): + name = serializers.CharField(required=True, max_length=150) + display_name = serializers.CharField(required=True, max_length=150) + organization_id = serializers.CharField(required=True, max_length=30) + + def validate_organization_id(self, value): # type: ignore + if not re.match(r"^[a-z0-9_-]+$", value): + raise serializers.ValidationError( + "organization_code should only contain " + "alphanumeric characters,_ and -." + ) + return value + + +class OrganizationCallbackSerializer(serializers.Serializer): + id = serializers.CharField(required=False) + + +class GetOrganizationsResponseSerializer(serializers.Serializer): + id = serializers.CharField() + display_name = serializers.CharField() + name = serializers.CharField() + # Add more fields as needed + + def to_representation(self, instance): # type: ignore + data = super().to_representation(instance) + # Modify the representation if needed + return data + + +class GetOrganizationMembersResponseSerializer(serializers.Serializer): + user_id = serializers.CharField() + email = serializers.CharField() + name = serializers.CharField() + picture = serializers.CharField() + # Add more fields as needed + + def to_representation(self, instance): # type: ignore + data = super().to_representation(instance) + # Modify the representation if needed + return data + + +class OrganizationSerializer(serializers.Serializer): + name = serializers.CharField() + organization_id = serializers.CharField() + + +class SetOrganizationsResponseSerializer(serializers.Serializer): + id = serializers.CharField() + email = serializers.CharField() + name = serializers.CharField() + display_name = serializers.CharField() + family_name = serializers.CharField() + picture = serializers.CharField() + # Add more fields as needed + + def to_representation(self, instance): # type: ignore + data = super().to_representation(instance) + # Modify the representation if needed + return data + + +class ModelTenantSerializer(serializers.ModelSerializer): + class Meta: + model = Organization + fields = fields = ("name", "created_on") + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ("id", "username") + + +class OrganizationSignupResponseSerializer(serializers.Serializer): + name = serializers.CharField() + display_name = serializers.CharField() + organization_id = serializers.CharField() + created_at = serializers.CharField() + + +class LoginRequestSerializer(serializers.Serializer): + username = serializers.CharField(required=True) + password = serializers.CharField(required=True) + + def validate_username(self, value: Optional[str]) -> str: + """Check that the username is not empty and has at least 3 + characters.""" + if not value or len(value) < 3: + raise serializers.ValidationError( + "Username must be at least 3 characters long." + ) + return value + + def validate_password(self, value: Optional[str]) -> str: + """Check that the password is not empty and has at least 3 + characters.""" + if not value or len(value) < 3: + raise serializers.ValidationError( + "Password must be at least 3 characters long." + ) + return value + + +class UserSessionResponseSerializer(serializers.Serializer): + id = serializers.IntegerField() + user_id = serializers.CharField() + email = serializers.CharField() + organization_id = serializers.CharField() diff --git a/backend/account_v2/subscription_loader.py b/backend/account_v2/subscription_loader.py new file mode 100644 index 0000000000..d380ed19dd --- /dev/null +++ b/backend/account_v2/subscription_loader.py @@ -0,0 +1,77 @@ +import logging +import os +from importlib import import_module +from typing import Any + +from django.apps import apps + +logger = logging.getLogger(__name__) + + +class SubscriptionConfig: + """Loader config for subscription plugins.""" + + PLUGINS_APP = "plugins" + PLUGIN_DIR = "subscription" + MODULE = "module" + METADATA = "metadata" + METADATA_NAME = "name" + METADATA_SERVICE_CLASS = "service_class" + METADATA_IS_ACTIVE = "is_active" + + +def load_plugins() -> list[Any]: + """Iterate through the subscription plugins and register them.""" + plugins_app = apps.get_app_config(SubscriptionConfig.PLUGINS_APP) + package_path = plugins_app.module.__package__ + subscription_dir = os.path.join(plugins_app.path, SubscriptionConfig.PLUGIN_DIR) + subscription_package_path = f"{package_path}.{SubscriptionConfig.PLUGIN_DIR}" + subscription_plugins: list[Any] = [] + + if not os.path.exists(subscription_dir): + return subscription_plugins + + for item in os.listdir(subscription_dir): + # Loads a plugin if it is in a directory. + if os.path.isdir(os.path.join(subscription_dir, item)): + subscription_module_name = item + # Loads a plugin if it is a shared library. + # Module name is extracted from shared library name. + # `subscription.platform_architecture.so` will be file name and + # `subscription` will be the module name. + elif item.endswith(".so"): + subscription_module_name = item.split(".")[0] + else: + continue + try: + full_module_path = f"{subscription_package_path}.{subscription_module_name}" + module = import_module(full_module_path) + metadata = getattr(module, SubscriptionConfig.METADATA, {}) + + if metadata.get(SubscriptionConfig.METADATA_IS_ACTIVE, False): + subscription_plugins.append( + { + SubscriptionConfig.MODULE: module, + SubscriptionConfig.METADATA: module.metadata, + } + ) + logger.info( + "Loaded subscription plugin: %s, is_active: %s", + module.metadata[SubscriptionConfig.METADATA_NAME], + module.metadata[SubscriptionConfig.METADATA_IS_ACTIVE], + ) + else: + logger.info( + "subscription plugin %s is not active.", + subscription_module_name, + ) + except ModuleNotFoundError as exception: + logger.error( + "Error while importing subscription plugin: %s", + exception, + ) + + if len(subscription_plugins) == 0: + logger.info("No subscription plugins found.") + + return subscription_plugins diff --git a/backend/account_v2/templates/index.html b/backend/account_v2/templates/index.html new file mode 100644 index 0000000000..ffa0b6085c --- /dev/null +++ b/backend/account_v2/templates/index.html @@ -0,0 +1,11 @@ + + + + + ZipstackID Django App Example + + +

Welcome Guest

+

Login

+ + diff --git a/backend/account_v2/templates/login.html b/backend/account_v2/templates/login.html new file mode 100644 index 0000000000..4edf8e3f1e --- /dev/null +++ b/backend/account_v2/templates/login.html @@ -0,0 +1,134 @@ + + + + + + Login + + + +
+
+ +
+
+ {% load static %} +
+ My image +
+ + {% if error_message %} +

{{ error_message }}

+ {% endif %} + {% csrf_token %} + + + +

+ +
+ + + diff --git a/backend/account_v2/tests.py b/backend/account_v2/tests.py new file mode 100644 index 0000000000..a39b155ac3 --- /dev/null +++ b/backend/account_v2/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/backend/account_v2/urls.py b/backend/account_v2/urls.py new file mode 100644 index 0000000000..767be8aba9 --- /dev/null +++ b/backend/account_v2/urls.py @@ -0,0 +1,22 @@ +from account_v2.views import ( + callback, + create_organization, + get_organizations, + get_session_data, + login, + logout, + set_organization, + signup, +) +from django.urls import path + +urlpatterns = [ + path("login", login, name="login"), + path("signup", signup, name="signup"), + path("logout", logout, name="logout"), + path("callback", callback, name="callback"), + path("session", get_session_data, name="session"), + path("organizations", get_organizations, name="get_organizations"), + path("organization//set", set_organization, name="set_organization"), + path("organization/create", create_organization, name="create_organization"), +] diff --git a/backend/account_v2/user.py b/backend/account_v2/user.py new file mode 100644 index 0000000000..7967521e9d --- /dev/null +++ b/backend/account_v2/user.py @@ -0,0 +1,55 @@ +import logging +from typing import Any, Optional + +from account_v2.models import User +from django.db import IntegrityError + +Logger = logging.getLogger(__name__) + + +class UserService: + def __init__( + self, + ) -> None: + pass + + def create_user(self, email: str, user_id: str) -> User: + try: + user: User = User(email=email, user_id=user_id, username=email) + user.save() + except IntegrityError as error: + Logger.info(f"[Duplicate Id] Failed to create User Error: {error}") + raise error + return user + + def update_user(self, user: User, user_id: str) -> User: + user.user_id = user_id + user.save() + return user + + def get_user_by_email(self, email: str) -> Optional[User]: + try: + user: User = User.objects.get(email=email) + return user + except User.DoesNotExist: + return None + + def get_user_by_user_id(self, user_id: str) -> Any: + try: + return User.objects.get(user_id=user_id) + except User.DoesNotExist: + return None + + def get_user_by_id(self, id: str) -> Any: + """Retrieve a user by their ID, taking into account the schema context. + + Args: + id (str): The ID of the user. + + Returns: + Any: The user object if found, or None if not found. + """ + try: + return User.objects.get(id=id) + except User.DoesNotExist: + return None diff --git a/backend/account_v2/views.py b/backend/account_v2/views.py new file mode 100644 index 0000000000..b73add92a4 --- /dev/null +++ b/backend/account_v2/views.py @@ -0,0 +1,169 @@ +import logging +from typing import Any + +from account_v2.authentication_controller import AuthenticationController +from account_v2.dto import ( + OrganizationSignupRequestBody, + OrganizationSignupResponse, + UserSessionInfo, +) +from account_v2.models import Organization +from account_v2.organization import OrganizationService +from account_v2.serializer import ( + OrganizationSignupResponseSerializer, + OrganizationSignupSerializer, + UserSessionResponseSerializer, +) +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.request import Request +from rest_framework.response import Response +from utils.user_session import UserSessionUtils + +Logger = logging.getLogger(__name__) + + +@api_view(["POST"]) +def create_organization(request: Request) -> Response: + serializer = OrganizationSignupSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + requestBody: OrganizationSignupRequestBody = makeSignupRequestParams(serializer) + + organization: Organization = OrganizationService.create_organization( + requestBody.name, + requestBody.display_name, + requestBody.organization_id, + ) + response = makeSignupResponse(organization) + return Response( + status=status.HTTP_201_CREATED, + data={"message": "success", "tenant": response}, + ) + except Exception as error: + Logger.error(error) + return Response( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, data="Unknown Error" + ) + + +@api_view(["GET"]) +def callback(request: Request) -> Response: + auth_controller = AuthenticationController() + return auth_controller.authorization_callback(request) + + +@api_view(["GET", "POST"]) +def login(request: Request) -> Response: + auth_controller = AuthenticationController() + return auth_controller.user_login(request) + + +@api_view(["GET"]) +def signup(request: Request) -> Response: + auth_controller = AuthenticationController() + return auth_controller.user_signup(request) + + +@api_view(["GET"]) +def logout(request: Request) -> Response: + auth_controller = AuthenticationController() + return auth_controller.user_logout(request) + + +@api_view(["GET"]) +def get_organizations(request: Request) -> Response: + """get_organizations. + + Retrieve the list of organizations to which the user belongs. + Args: + request (HttpRequest): _description_ + + Returns: + Response: A list of organizations with associated information. + """ + auth_controller = AuthenticationController() + return auth_controller.user_organizations(request) + + +@api_view(["POST"]) +def set_organization(request: Request, id: str) -> Response: + """set_organization. + + Set the current organization to use. + Args: + request (HttpRequest): _description_ + id (String): organization Id + + Returns: + Response: Contains the User and Current organization details. + """ + + auth_controller = AuthenticationController() + return auth_controller.set_user_organization(request, id) + + +@api_view(["GET"]) +def get_session_data(request: Request) -> Response: + """get_session_data. + + Retrieve the current session data. + Args: + request (HttpRequest): _description_ + + Returns: + Response: Contains the User and Current organization details. + """ + response = make_session_response(request) + + return Response( + status=status.HTTP_201_CREATED, + data=response, + ) + + +def make_session_response( + request: Request, +) -> Any: + """make_session_response. + + Make the current session data. + Args: + request (HttpRequest): _description_ + + Returns: + User and Current organization details. + """ + auth_controller = AuthenticationController() + return UserSessionResponseSerializer( + UserSessionInfo( + id=request.user.id, + user_id=request.user.user_id, + email=request.user.email, + user=auth_controller.get_user_info(request), + organization_id=UserSessionUtils.get_organization_id(request), + ) + ).data + + +def makeSignupRequestParams( + serializer: OrganizationSignupSerializer, +) -> OrganizationSignupRequestBody: + return OrganizationSignupRequestBody( + serializer.validated_data["name"], + serializer.validated_data["display_name"], + serializer.validated_data["organization_id"], + ) + + +def makeSignupResponse( + organization: Organization, +) -> Any: + return OrganizationSignupResponseSerializer( + OrganizationSignupResponse( + organization.name, + organization.display_name, + organization.organization_id, + organization.created_at, + ) + ).data From 8b250d8e4b9cd6064af39ca2e2cbec58a3590d74 Mon Sep 17 00:00:00 2001 From: ali-zipstack Date: Thu, 11 Jul 2024 12:00:24 +0530 Subject: [PATCH 2/2] updated reverse foreign keys --- backend/account_v2/authentication_helper.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/account_v2/authentication_helper.py b/backend/account_v2/authentication_helper.py index d2159a0f4d..e303445fcc 100644 --- a/backend/account_v2/authentication_helper.py +++ b/backend/account_v2/authentication_helper.py @@ -96,9 +96,9 @@ def remove_users_from_organization_by_pks( OrganizationMemberService.remove_users_by_user_pks(user_pks) # removing user m2m relations , while removing user for user_pk in user_pks: - User.objects.get(pk=user_pk).shared_exported_tools.clear() - User.objects.get(pk=user_pk).shared_custom_tool.clear() - User.objects.get(pk=user_pk).shared_adapters.clear() + User.objects.get(pk=user_pk).prompt_registries.clear() + User.objects.get(pk=user_pk).shared_custom_tools.clear() + User.objects.get(pk=user_pk).shared_adapters_instance.clear() @staticmethod def remove_user_from_organization_by_user_id( @@ -112,9 +112,9 @@ def remove_user_from_organization_by_user_id( # removing user from organization OrganizationMemberService.remove_user_by_user_id(user_id) # removing user m2m relations , while removing user - User.objects.get(user_id=user_id).shared_exported_tools.clear() - User.objects.get(user_id=user_id).shared_custom_tool.clear() - User.objects.get(user_id=user_id).shared_adapters.clear() + User.objects.get(user_id=user_id).prompt_registries.clear() + User.objects.get(user_id=user_id).shared_custom_tools.clear() + User.objects.get(user_id=user_id).shared_adapters_instance.clear() # removing user from organization cache OrganizationMemberService.remove_user_membership_in_organization_cache( user_id=user_id, organization_id=organization_id