diff --git a/README.md b/README.md index 1846f7f8..9dc314fd 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,8 @@ You can set environment variables in your OS, write on ```.env``` file or pass v | OIDC_RP_SCOPES | ```string``` | ```openid email``` | The OpenID Connect scopes to request during login. | CONNECT_GRPC_SERVER_URL | ```string``` | ```localhost:8002``` | Define grpc connect server url | CONNECT_CERTIFICATE_GRPC_CRT | ```string``` | ```None``` | Absolute certificate path for secure grpc communication +| CONNECT_API_URL | ```string``` | ```None``` | Connect module api url +| USE_GRPC | ```bool``` | ```False``` | Use connect gRPC clients | RECAPTCHA_SECRET_KEY | ```string``` | ```''``` | Token of the recaptcha used in the validation of a user's registration. | REPOSITORY_NLP_LOG_LIMIT | ```int``` | ```10000``` | Limit of query size to repository log. | REPOSITORY_RESTRICT_ACCESS_NLP_LOGS | ```list``` | ```[]``` | Restricts log access to a particular or multiple intelligences diff --git a/bothub/api/grpc/repository/serializers.py b/bothub/api/grpc/repository/serializers.py index 901f6d3f..1c8536e6 100644 --- a/bothub/api/grpc/repository/serializers.py +++ b/bothub/api/grpc/repository/serializers.py @@ -5,6 +5,8 @@ from bothub.common.models import Repository from weni.protobuf.intelligence import repository_pb2 +from bothub.utils import internal_serializer_fields + class RepositoryProtoSerializer(proto_serializers.ModelProtoSerializer): owner__nickname = serializers.SerializerMethodField() @@ -15,26 +17,7 @@ class RepositoryProtoSerializer(proto_serializers.ModelProtoSerializer): class Meta: model = Repository proto_class = repository_pb2.Repository - fields = [ - "uuid", - "name", - "slug", - "description", - "is_private", - "created_at", - "language", - "owner", - "algorithm", - "use_competing_intents", - "use_name_entities", - "use_analyze_char", - "owner__nickname", - "intents", - "categories", - "available_languages", - "categories_list", - "repository_type", - ] + fields = internal_serializer_fields def get_owner__nickname(self, repository: Repository): return repository.owner.nickname diff --git a/bothub/api/v2/internal/__init__.py b/bothub/api/v2/internal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bothub/api/v2/internal/connect_rest_client.py b/bothub/api/v2/internal/connect_rest_client.py new file mode 100644 index 00000000..df2409d0 --- /dev/null +++ b/bothub/api/v2/internal/connect_rest_client.py @@ -0,0 +1,81 @@ +import requests +from typing import List, Dict + +from django.conf import settings + + +class ConnectRESTClient: + def __init__(self): + self.base_url = settings.CONNECT_API_URL + self.headers = { + "Content-Type": "application/json; charset: utf-8", + "Authorization": self.get_auth_token(), + } + + def get_auth_token(self) -> str: + request = requests.post( + url=settings.OIDC_OP_TOKEN_ENDPOINT, + data={ + "client_id": settings.OIDC_RP_CLIENT_ID, + "client_secret": settings.OIDC_RP_CLIENT_SECRET, + "grant_type": "client_credentials", + }, + ) + token = request.json().get("access_token") + return f"Bearer {token}" + + def list_classifiers( + self, project_uuid: str, user_email: str + ) -> List[Dict[str, str]]: + request = requests.get( + url=f"{self.base_url}/v1/organization/project/list_classifier/", + headers=self.headers, + params={"project_uuid": project_uuid, "user_email": user_email}, + ) + + return request.json()["data"] + + def list_authorizations(self, project_uuid: str, user_email: str) -> List[str]: + classifiers = self.list_classifiers( + project_uuid=project_uuid, user_email=user_email + ) + + return [classifier.get("authorization_uuid") for classifier in classifiers] + + def get_authorization_classifier( + self, project_uuid: str, authorization_uuid: str, user_email: str + ) -> str: + """ + Recives a authorization UUID and returns the respective classifier UUID + """ + classifiers = self.list_classifiers(project_uuid, user_email) + classifier = filter( + lambda classifier: classifier["authorization_uuid"] == authorization_uuid, + classifiers, + ) + + return next(classifier).get("uuid") + + def remove_authorization( + self, project_uuid: str, authorization_uuid: str, user_email: str + ): + classifier_uuid = self.get_authorization_classifier( + project_uuid, + authorization_uuid, + user_email, + ) + request = requests.delete( + url=f"{self.base_url}/v1/organization/project/destroy_classifier/", + headers=self.headers, + params={"uuid": classifier_uuid, "user_email": user_email}, + ) + + return request.json() + + def create_classifier(self, **kwargs): + request = requests.post( + url=f"{self.base_url}/v1/organization/project/create_classifier/", + headers=self.headers, + params={**kwargs, "classifier_type": "bothub"}, + ) + return request.json() diff --git a/bothub/api/v2/internal/organization/__init__.py b/bothub/api/v2/internal/organization/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bothub/api/v2/internal/organization/permissions.py b/bothub/api/v2/internal/organization/permissions.py new file mode 100644 index 00000000..6edbf607 --- /dev/null +++ b/bothub/api/v2/internal/organization/permissions.py @@ -0,0 +1,11 @@ +from rest_framework import permissions + +from bothub.utils import get_user + + +class InternalOrganizationAdminHasPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + authorization = obj.organization.get_organization_authorization( + get_user(request.query_params.get("user_email", None)) + ) + return authorization.is_admin diff --git a/bothub/api/v2/internal/organization/serializers.py b/bothub/api/v2/internal/organization/serializers.py new file mode 100644 index 00000000..26456b3a --- /dev/null +++ b/bothub/api/v2/internal/organization/serializers.py @@ -0,0 +1,83 @@ +from django.db.models import F +from django.utils.translation import ugettext_lazy as _ + +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from bothub.authentication.models import User +from bothub.common.models import Organization, OrganizationAuthorization + + +class OrganizationSerializer(serializers.ModelSerializer): + + users = serializers.SerializerMethodField() + + def get_users(self, org: Organization): + return list( + org.organization_authorizations.exclude( + role=OrganizationAuthorization.LEVEL_NOTHING + ) + .annotate( + org_user_id=F("user__user_owner__pk"), + org_user_email=F("user__user_owner__email"), + org_user_nickname=F("user__user_owner__nickname"), + org_user_name=F("user__user_owner__name"), + ) + .values( + "org_user_id", "org_user_email", "org_user_nickname", "org_user_name" + ) + ) + + class Meta: + model = Organization + fields = ["id", "name", "users"] + + +class OrgCreateSerializer(serializers.ModelSerializer): + + organization_name = serializers.CharField() + user_email = serializers.CharField() + + def validate_user_email(self, value: str) -> str: + try: + User.objects.get(email=value) + except User.DoesNotExist: + raise ValidationError(_("{} not found!").format(value)) + + return value + + class Meta: + model = Organization + fields = ["organization_name", "user_email"] + + +class OrgUpdateSerializer(serializers.ModelSerializer): + + id = serializers.IntegerField() + name = serializers.CharField(required=False) + + def validate_id(self, value): + try: + Organization.objects.get(pk=value) + except Organization.DoesNotExist: + raise ValidationError(f"{value} not found!") + + return value + + def save(self): + data = dict(self.validated_data) + + org = Organization.objects.get(pk=data.get("id")) + + updated_fields = self.get_updated_fields(data) + + if updated_fields: + org.__dict__.update(**updated_fields) + org.save() + + def get_updated_fields(self, data): + return {key: value for key, value in data.items() if key not in ["id"]} + + class Meta: + model = Organization + fields = ["id", "name"] diff --git a/bothub/api/v2/internal/organization/tests.py b/bothub/api/v2/internal/organization/tests.py new file mode 100644 index 00000000..4ac09bf6 --- /dev/null +++ b/bothub/api/v2/internal/organization/tests.py @@ -0,0 +1,232 @@ +import json + +from django.test import tag +from django.test import RequestFactory +from django.test import TestCase +from rest_framework import status + +from bothub.api.v2.internal.organization.views import InternalOrganizationViewSet +from bothub.api.v2.tests.utils import create_user_and_token +from bothub.common.models import ( + Organization, + OrganizationAuthorization, + RepositoryCategory, +) +from bothub.api.v2.tests.utils import get_valid_mockups, create_repository_from_mockup + + +class InternalOrganizationTestCase(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.moduser, self.moduser_token = create_user_and_token("module", module=True) + self.owner, self.owner_token = create_user_and_token("owner") + self.category = RepositoryCategory.objects.create(name="Category 1") + self.org = Organization.objects.create( + name="Organization 1", nickname="organization1" + ) + self.org_2 = Organization.objects.create( + name="Organization 2", nickname="organization2" + ) + OrganizationAuthorization.objects.create( + user=self.owner, + organization=self.org, + role=OrganizationAuthorization.ROLE_ADMIN, + ) + OrganizationAuthorization.objects.create( + user=self.owner, + organization=self.org_2, + role=OrganizationAuthorization.ROLE_ADMIN, + ) + self.repositories = [ + create_repository_from_mockup(self.org, **mockup) + for mockup in get_valid_mockups([self.category]) + ] + for rep in self.repositories: + rep.get_user_authorization(self.org) + + self.repository_auth = self.repositories[0].get_user_authorization(self.owner) + + +def auth_header(token): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + authorization_header["content_type"] = "application/json" + + return authorization_header + + +@tag("internal") +class InternalOrganizationListTestCase(InternalOrganizationTestCase): + def request(self, params, token=None): + authorization_header = auth_header(token) + + request = self.factory.get( + "/v2/internal/organization/", params, **authorization_header + ) + response = InternalOrganizationViewSet.as_view({"get": "list"})(request) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + {"user_email": self.owner.email}, + self.moduser_token, + ) + self.assertEqual(len(content_data), 2) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_not_ok(self): + response, content_data = self.request( + {"user_email": self.owner.email}, + self.owner_token, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalOrganizationCreateTestCase(InternalOrganizationTestCase): + def request(self, data, token=None): + authorization_header = auth_header(token) + + request = self.factory.post( + "/v2/internal/organization/", data, **authorization_header + ) + + response = InternalOrganizationViewSet.as_view({"post": "create"})(request) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + {"user_email": self.owner.email, "organization_name": "org3"}, + self.moduser_token, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + organization = Organization.objects.get(pk=content_data.get("id")) + self.assertEqual(organization.name, "org3") + organization_authorization = OrganizationAuthorization.objects.filter( + organization=organization, user=self.owner + ) + self.assertEqual(organization_authorization.count(), 1) + self.assertEqual( + organization_authorization.first().role, + OrganizationAuthorization.ROLE_ADMIN, + ) + + def test_not_ok(self): + response, content_data = self.request( + {"user_email": self.owner.email, "organization_name": "org3"}, + self.owner_token, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalOrganizationDestroyTestCase(InternalOrganizationTestCase): + def request(self, email, id, token=None): + authorization_header = auth_header(token) + + request = self.factory.delete( + f"/v2/internal/organization/?user_email={email}", **authorization_header + ) + + response = InternalOrganizationViewSet.as_view({"delete": "destroy"})( + request, pk=id + ) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + self.owner.email, + self.org_2.id, + self.moduser_token, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + organization = Organization.objects.filter(pk=self.org_2.pk) + self.assertEqual(organization.count(), 0) + + def test_not_ok(self): + response, content_data = self.request( + self.owner.email, + self.org_2.id, + self.owner_token, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalOrganizationUpdateTestCase(InternalOrganizationTestCase): + def request(self, data, email, id, token=None): + authorization_header = auth_header(token) + + request = self.factory.put( + f"/v2/internal/organization/?user_email={email}", + data, + **authorization_header, + ) + response = InternalOrganizationViewSet.as_view({"put": "update"})( + request, pk=id + ) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + {"name": "org222"}, + self.owner.email, + self.org_2.id, + self.moduser_token, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + organization = Organization.objects.get(pk=self.org_2.pk) + + self.assertEqual(organization.name, "org222") + + def test_not_ok(self): + response, content_data = self.request( + {"name": "org222"}, + self.owner.email, + self.org_2.id, + self.owner_token, + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalOrganizationRetrieveTestCase(InternalOrganizationTestCase): + def request(self, email, id, token=None): + authorization_header = auth_header(token) + + request = self.factory.get( + f"/v2/internal/organization/?user_email={email}", **authorization_header + ) + response = InternalOrganizationViewSet.as_view({"get": "retrieve"})( + request, pk=id + ) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + self.owner.email, + self.org.id, + self.moduser_token, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(content_data["repositories_count"], 2) + + def test_not_ok(self): + response, content_data = self.request( + self.owner.email, + self.org.id, + self.owner_token, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/bothub/api/v2/internal/organization/views.py b/bothub/api/v2/internal/organization/views.py new file mode 100644 index 00000000..3fe839f1 --- /dev/null +++ b/bothub/api/v2/internal/organization/views.py @@ -0,0 +1,120 @@ +from django.utils.translation import ugettext_lazy as _ +from django.db import models +from rest_framework import mixins +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from bothub.api.v2.metadata import Metadata +from bothub.authentication.models import User +from bothub.common.models import ( + Organization, + OrganizationAuthorization, + Repository, + RepositoryAuthorization, +) +from bothub.api.v2.internal.organization.serializers import ( + OrganizationSerializer, + OrgCreateSerializer, + OrgUpdateSerializer, +) +from bothub import utils +from bothub.api.v2.internal.permissions import ModuleHasPermission +from bothub.api.v2.internal.organization.permissions import ( + InternalOrganizationAdminHasPermission, +) + + +class InternalOrganizationViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + GenericViewSet, +): + queryset = Organization.objects.all() + serializer_class = OrganizationSerializer + lookup_field = "pk" + permission_classes = [ModuleHasPermission] + metadata_class = Metadata + + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view requires. + """ + + permission_classes = self.permission_classes + if self.action in ["destroy", "update"]: + permission_classes.append(InternalOrganizationAdminHasPermission) + return [permission() for permission in self.permission_classes] + + def list(self, request, *args, **kwargs): + user = utils.get_user(request.query_params.get("user_email")) + serializer = OrganizationSerializer(user.get_user_organizations, many=True) + + return Response(serializer.data) + + def create(self, request, *args, **kwargs): + user, created = User.objects.get_or_create( + email=request.data.get("user_email", None), + defaults={"nickname": request.data.get("user_email", None)}, + ) + + serializer = OrgCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + validated_data = { + "name": serializer.validated_data.get("organization_name"), + "nickname": utils.organization_unique_slug_generator( + serializer.validated_data.get("organization_name") + ), + "description": "", + "locale": "", + } + + org = Organization.objects.create(**validated_data) + + org.organization_authorizations.create( + user=user, role=OrganizationAuthorization.ROLE_ADMIN + ) + org_serializer = OrganizationSerializer(org) + + return Response(org_serializer.data) + + def destroy(self, request, *args, **kwargs): + org = self.get_object() + user, created = User.objects.get_or_create( + email=request.query_params.get("user_email"), + defaults={"nickname": request.query_params.get("user_email")}, + ) + + org.delete() + return Response({_("Organization deleted with success")}) + + def update(self, request, *args, **kwargs): + org = self.get_object() + data = {"id": org.pk, **request.data} + serializer = OrgUpdateSerializer(data=data) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data) + + def retrieve(self, request, *args, **kwargs): + org = self.get_object() + + auths = ( + RepositoryAuthorization.objects.exclude(repository__owner=org) + .exclude(role=RepositoryAuthorization.ROLE_NOT_SETTED) + .filter(user=org) + ) + + response = { + "repositories_count": int( + Repository.objects.filter( + models.Q(uuid__in=auths) | models.Q(owner=org) + ).count() + ) + } + + return Response(response) diff --git a/bothub/api/v2/internal/permissions.py b/bothub/api/v2/internal/permissions.py new file mode 100644 index 00000000..4974aa76 --- /dev/null +++ b/bothub/api/v2/internal/permissions.py @@ -0,0 +1,9 @@ +from rest_framework import permissions + + +class ModuleHasPermission(permissions.BasePermission): + def has_permission(self, request, view): # pragma: no cover + return request.user.has_perm("authentication.can_communicate_internally") + + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) diff --git a/bothub/api/v2/internal/repository/__init__.py b/bothub/api/v2/internal/repository/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bothub/api/v2/internal/repository/serializers.py b/bothub/api/v2/internal/repository/serializers.py new file mode 100644 index 00000000..58351bfd --- /dev/null +++ b/bothub/api/v2/internal/repository/serializers.py @@ -0,0 +1,29 @@ +from rest_framework import serializers + +from bothub.api.v2.repository.serializers import RepositoryCategorySerializer +from bothub.common.models import Repository + +from bothub.utils import internal_serializer_fields + + +class InternalRepositorySerializer(serializers.ModelSerializer): + owner__nickname = serializers.SerializerMethodField() + intents = serializers.SerializerMethodField() + available_languages = serializers.SerializerMethodField() + categories_list = serializers.SerializerMethodField() + + class Meta: + model = Repository + fields = internal_serializer_fields + + def get_owner__nickname(self, repository: Repository): + return repository.owner.nickname + + def get_intents(self, repository: Repository): + return repository.get_formatted_intents() + + def get_available_languages(self, repository: Repository): + return repository.available_languages() + + def get_categories_list(self, repository: Repository): + return RepositoryCategorySerializer(repository.categories, many=True).data diff --git a/bothub/api/v2/internal/repository/tests.py b/bothub/api/v2/internal/repository/tests.py new file mode 100644 index 00000000..9b83fe13 --- /dev/null +++ b/bothub/api/v2/internal/repository/tests.py @@ -0,0 +1,112 @@ +import json + +from django.test import tag +from django.test import RequestFactory +from django.test import TestCase +from rest_framework import status + +from bothub.api.v2.internal.repository.views import InternalRepositoriesViewSet +from bothub.api.v2.tests.utils import create_user_and_token +from bothub.common.models import ( + Organization, + OrganizationAuthorization, +) +from bothub.common.models import RepositoryCategory + +from bothub.api.v2.tests.utils import get_valid_mockups, create_repository_from_mockup + + +class InternalRepositoryTestCase(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.moduser, self.moduser_token = create_user_and_token("module", module=True) + self.owner, self.owner_token = create_user_and_token("owner") + self.category = RepositoryCategory.objects.create(name="Category 1") + self.org = Organization.objects.create( + name="Organization 1", nickname="organization1" + ) + OrganizationAuthorization.objects.create( + user=self.owner, + organization=self.org, + role=OrganizationAuthorization.ROLE_ADMIN, + ) + self.repositories = [ + create_repository_from_mockup(self.org, **mockup) + for mockup in get_valid_mockups([self.category]) + ] + for rep in self.repositories: + rep.get_user_authorization(self.org) + + self.repository_auth = self.repositories[0].get_user_authorization(self.owner) + + +@tag("internal") +class InternalRepositoryListTestCase(InternalRepositoryTestCase): + def request(self, params, token=None): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + + request = self.factory.get( + "/v2/internal/repository/", + params, + **authorization_header, + ) + response = InternalRepositoriesViewSet.as_view({"get": "list"})(request) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + {"name": "Repository 1", "org_id": self.org.id}, self.moduser_token + ) + self.assertEqual(content_data["count"], 1) + self.assertEqual(len(content_data["results"]), 1) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_return_all_repos(self): + response, content_data = self.request( + {"org_id": self.org.id}, self.moduser_token + ) + + self.assertEqual(content_data["count"], 2) + self.assertEqual(len(content_data["results"]), 2) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_fail_module(self): + response, content_data = self.request({"org_id": self.org.pk}, self.owner_token) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalRepositoryRetrieveAuthorizationTestCase(InternalRepositoryTestCase): + def request(self, params, token=None): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + request = self.factory.get( + "/v2/internal/repository/RetrieveAuthorization/", + params, + **authorization_header, + ) + response = InternalRepositoriesViewSet.as_view( + {"get": "retrieve_authorization"} + )(request) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok_repository_1(self): + response, content_data = self.request( + {"repository_authorization": self.repository_auth.pk}, self.moduser_token + ) + self.assertEqual(content_data["name"], "Repository 1") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_not_ok(self): + response, content_data = self.request( + {"repository_authorization": self.repository_auth.pk}, self.owner_token + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/bothub/api/v2/internal/repository/views.py b/bothub/api/v2/internal/repository/views.py new file mode 100644 index 00000000..5b769250 --- /dev/null +++ b/bothub/api/v2/internal/repository/views.py @@ -0,0 +1,39 @@ +from rest_framework import mixins +from rest_framework.decorators import action +from rest_framework.filters import SearchFilter +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from bothub.common.models import Repository + +from bothub.api.v2.internal.repository.serializers import InternalRepositorySerializer +from bothub.api.v2.internal.permissions import ModuleHasPermission + + +class InternalRepositoriesViewSet(mixins.ListModelMixin, GenericViewSet): + serializer_class = InternalRepositorySerializer + queryset = Repository.objects + permission_classes = [ModuleHasPermission] + filter_backends = [SearchFilter] + search_fields = ["$name", "^name", "=name"] + + def get_queryset(self, *args, **kwargs): + queryset = self.queryset.all() + name = self.request.query_params.get("name", None) + if name: + queryset = self.queryset.filter(name__icontains=name) + org_id = self.request.query_params.get("org_id", None) + if org_id: + queryset = queryset.filter(authorizations__user__pk=org_id) + + return queryset[:20] + + @action(detail=True, methods=["GET"], url_name="retrieve-authorization") + def retrieve_authorization(self, request, **kwargs): + auth = self.request.query_params.get("repository_authorization", None) + repository = Repository.objects.none() + if auth: + repository = Repository.objects.get(authorizations__uuid=auth) + serialized_data = InternalRepositorySerializer(repository) + + return Response(serialized_data.data) diff --git a/bothub/api/v2/internal/user/__init__.py b/bothub/api/v2/internal/user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bothub/api/v2/internal/user/serializers.py b/bothub/api/v2/internal/user/serializers.py new file mode 100644 index 00000000..e58a7c70 --- /dev/null +++ b/bothub/api/v2/internal/user/serializers.py @@ -0,0 +1,37 @@ +from rest_framework import serializers + +from bothub.authentication.models import User + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = [ + "id", + "email", + "nickname", + "name", + "language", + "joined_at", + "is_active", + "is_superuser", + ] + + +class UserPermissionSerializer(serializers.Serializer): + role = serializers.IntegerField() + + +class UserLanguageSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["language"] + read_only = [ + "id", + "email", + "nickname", + "name", + "joined_at", + "is_active", + "is_superuser", + ] diff --git a/bothub/api/v2/internal/user/tests.py b/bothub/api/v2/internal/user/tests.py new file mode 100644 index 00000000..20000083 --- /dev/null +++ b/bothub/api/v2/internal/user/tests.py @@ -0,0 +1,208 @@ +import json +from bothub.authentication.models import User + +from django.test import tag +from django.test import RequestFactory +from django.test import TestCase +from rest_framework import status + +from bothub.api.v2.internal.user.views import ( + UserPermissionViewSet, + UserViewSet, + UserLanguageViewSet, +) +from bothub.api.v2.tests.utils import create_user_and_token +from bothub.common.models import ( + Organization, + OrganizationAuthorization, + RepositoryCategory, +) + + +class InternalUserTestCase(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.moduser, self.moduser_token = create_user_and_token("module", module=True) + self.owner, self.owner_token = create_user_and_token("owner") + self.user, self.user_token = create_user_and_token("user") + self.category = RepositoryCategory.objects.create(name="Category 1") + self.org = Organization.objects.create( + name="Organization 1", nickname="organization1" + ) + OrganizationAuthorization.objects.create( + user=self.owner, + organization=self.org, + role=OrganizationAuthorization.ROLE_ADMIN, + ) + OrganizationAuthorization.objects.create( + user=self.user, + organization=self.org, + role=OrganizationAuthorization.ROLE_CONTRIBUTOR, + ) + + +@tag("internal") +class InternalUserPermissionRetrieveTestCase(InternalUserTestCase): + def request(self, params, token=None): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + + request = self.factory.get( + "/v2/internal/user/permission", params, **authorization_header + ) + response = UserPermissionViewSet.as_view({"get": "retrieve"})(request) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + {"user_email": self.owner.email, "org_id": self.org.pk}, + token=self.moduser_token, + ) + self.assertEqual(content_data["role"], OrganizationAuthorization.ROLE_ADMIN) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_not_ok(self): + response, content_data = self.request( + {"user_email": self.owner.email, "org_id": self.org.pk}, + token=self.owner_token, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalUserPermissionUpdateTestCase(InternalUserTestCase): + def request(self, params, user_email, org_id, token=None): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + authorization_header["content_type"] = "application/json" + + request = self.factory.put( + f"/v2/internal/user/permission?user_email={user_email}&org_id={org_id}", + params, + **authorization_header, + ) + response = UserPermissionViewSet.as_view({"put": "update"})(request) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + {"role": OrganizationAuthorization.ROLE_CONTRIBUTOR}, + user_email=self.owner.email, + org_id=self.org.pk, + token=self.moduser_token, + ) + auth = OrganizationAuthorization.objects.get( + user=self.owner, organization=self.org + ) + self.assertEqual( + content_data["role"], OrganizationAuthorization.ROLE_CONTRIBUTOR + ) + self.assertEqual(content_data["role"], auth.role) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_not_ok(self): + response, content_data = self.request( + {"role": OrganizationAuthorization.ROLE_CONTRIBUTOR}, + user_email=self.owner.email, + org_id=self.org.pk, + token=self.owner_token, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalUserPermissionDeleteTestCase(InternalUserTestCase): + def request(self, user_email, org_id, token=None): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + authorization_header["content_type"] = "application/json" + + request = self.factory.delete( + f"/v2/internal/user/permission?user_email={user_email}&org_id={org_id}", + {}, + **authorization_header, + ) + response = UserPermissionViewSet.as_view({"delete": "destroy"})(request) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + user_email=self.owner.email, org_id=self.org.pk, token=self.moduser_token + ) + self.assertEqual(content_data["role"], None) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_not_ok(self): + response, content_data = self.request( + user_email=self.owner.email, org_id=self.org.pk, token=self.owner_token + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalUserRetrieveTestCase(InternalUserTestCase): + def request(self, params, token=None): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + + request = self.factory.get("/v2/internal/user", params, **authorization_header) + response = UserViewSet.as_view({"get": "retrieve"})(request) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + {"user_email": self.owner.email}, token=self.moduser_token + ) + self.assertEqual(content_data["email"], self.owner.email) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_not_ok(self): + response, content_data = self.request( + {"user_email": self.owner.email}, token=self.owner_token + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalUserLanguageUpdateTestCase(InternalUserTestCase): + def request(self, params, user_email, token=None): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + authorization_header["content_type"] = "application/json" + + request = self.factory.put( + f"/v2/internal/user/language?user_email={user_email}", + params, + **authorization_header, + ) + response = UserLanguageViewSet.as_view({"put": "update"})(request) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + {"language": "es"}, user_email=self.owner.email, token=self.moduser_token + ) + user = User.objects.get(email=self.owner.email) + self.assertEqual(content_data["language"], user.language) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_not_ok(self): + response, content_data = self.request( + {"language": "es"}, user_email=self.owner.email, token=self.owner_token + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/bothub/api/v2/internal/user/views.py b/bothub/api/v2/internal/user/views.py new file mode 100644 index 00000000..7c206595 --- /dev/null +++ b/bothub/api/v2/internal/user/views.py @@ -0,0 +1,98 @@ +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet +from rest_framework import mixins + +from bothub.authentication.models import User +from bothub.common.models import Organization, OrganizationAuthorization +from bothub.api.v2.internal.user.serializers import ( + UserPermissionSerializer, + UserSerializer, + UserLanguageSerializer, +) +from bothub import utils +from bothub.api.v2.internal.permissions import ModuleHasPermission + + +class UserPermissionViewSet( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + GenericViewSet, +): + queryset = OrganizationAuthorization.objects.all() + permission_classes = [ModuleHasPermission] + serializer_class = UserPermissionSerializer + lookup_field = None + + def retrieve(self, request, **kwargs): + user, org = utils.get_user_and_organization( + request.query_params.get("user_email", None), + request.query_params.get("org_id", None), + ) + permissions = self._get_user_permissions(org, user) + serializer = UserPermissionSerializer(permissions) + + return Response(serializer.data) + + def update(self, request, **kwargs): + user, org = utils.get_user_and_organization( + request.query_params.get("user_email", None), + request.query_params.get("org_id", None), + ) + + org.set_user_permission(user=user, permission=request.data.get("role", None)) + + permission = self._get_user_permissions(org, user) + serializer = UserPermissionSerializer(permission) + + return Response(serializer.data) + + def destroy(self, request, **kwargs): + user, org = utils.get_user_and_organization( + request.query_params.get("user_email", None), + request.query_params.get("org_id", None), + ) + + self._get_user_permissions(org, user).delete() + + permissions = self._get_user_permissions(org, user) + serializer = UserPermissionSerializer(permissions) + + return Response(serializer.data) + + def _get_user_permissions(self, org: Organization, user: User) -> dict: + return org.organization_authorizations.filter(user=user).first() + + +class UserViewSet(mixins.RetrieveModelMixin, GenericViewSet): + serializer_class = UserSerializer + permission_classes = [ModuleHasPermission] + queryset = User.objects + lookup_field = None + + def retrieve(self, request, **kwargs): + user, created = User.objects.get_or_create( + email=request.query_params.get("user_email"), + defaults={"nickname": request.query_params.get("user_email")}, + ) + + return Response(UserSerializer(user).data) + + +class UserLanguageViewSet(mixins.UpdateModelMixin, GenericViewSet): + serializer_class = UserLanguageSerializer + permission_classes = [ModuleHasPermission] + queryset = User.objects + lookup_field = None + + def update(self, request, **kwargs): + user, created = User.objects.get_or_create( + email=request.query_params.get("user_email"), + defaults={"nickname": request.query_params.get("user_email")}, + ) + serializer = UserLanguageSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user.language = request.data["language"] + user.save() + return Response(UserLanguageSerializer(user).data) diff --git a/bothub/api/v2/repository/serializers.py b/bothub/api/v2/repository/serializers.py index 22bfd3d9..c0370173 100644 --- a/bothub/api/v2/repository/serializers.py +++ b/bothub/api/v2/repository/serializers.py @@ -59,6 +59,10 @@ CanContributeInRepositoryTranslatedExampleValidator, ) +from bothub.api.v2.internal.connect_rest_client import ( + ConnectRESTClient as ConnectClient, +) + class RequestRepositoryAuthorizationSerializer(serializers.ModelSerializer): class Meta: @@ -1654,10 +1658,13 @@ class AddRepositoryProjectSerializer(serializers.Serializer): project_uuid = serializers.CharField(required=True) def create(self, validated_data): - task = celery_app.send_task( - name="create_repository_project", kwargs=validated_data - ) - task.wait() + if settings.USE_GRPC: + task = celery_app.send_task( + name="create_repository_project", kwargs=validated_data + ) + task.wait() + else: + task = ConnectClient().create_classifier(**validated_data) return validated_data def to_representation(self, instance): diff --git a/bothub/api/v2/repository/views.py b/bothub/api/v2/repository/views.py index a69918f1..d988c7f1 100644 --- a/bothub/api/v2/repository/views.py +++ b/bothub/api/v2/repository/views.py @@ -121,6 +121,10 @@ AddRepositoryProjectSerializer, ) +from bothub.api.v2.internal.connect_rest_client import ( + ConnectRESTClient as ConnectClient, +) + class NewRepositoryViewSet( MultipleFieldLookupMixin, mixins.RetrieveModelMixin, GenericViewSet @@ -249,19 +253,26 @@ def projectrepository(self, request, **kwargs): if not authorization.can_contribute: raise PermissionDenied() - task = celery_app.send_task( - name="get_project_organization", args=[project_uuid] - ) - task.wait() + if settings.USE_GRPC: + task = celery_app.send_task( + name="get_project_organization", args=[project_uuid] + ) + task.wait() + authorizations = task.result + repositories = repository.authorizations.filter(uuid__in=authorizations) - repositories = repository.authorizations.filter(uuid__in=task.result) + else: + authorizations = ConnectClient().list_authorizations( + project_uuid=project_uuid, user_email=request.user.email + ) + repositories = repository.authorizations.filter(uuid__in=authorizations) data = dict(in_project=repositories.exists()) if organization: organization_authorization = ( - organization.organization_authorizations.filter(uuid__in=task.result) + organization.organization_authorizations.filter(uuid__in=authorizations) ) data["in_project"] = ( data["in_project"] or organization_authorization.exists() @@ -301,16 +312,27 @@ def remove_repository_project(self, request, **kwargs): if not user_authorization.is_admin: raise PermissionDenied() - project_organization = celery_app.send_task( - name="get_project_organization", args=[project_uuid] - ) - project_organization.wait() + if settings.USE_GRPC: + project_organization = celery_app.send_task( + name="get_project_organization", args=[project_uuid] + ) + project_organization.wait() + project_organization = project_organization.result + authorizations = list( + repository.authorizations.filter( + uuid__in=project_organization + ).values_list("uuid", flat=True) + ) - authorizations = list( - repository.authorizations.filter( - uuid__in=project_organization.result - ).values_list("uuid", flat=True) - ) + else: + project_organization = ConnectClient().list_authorizations( + project_uuid=project_uuid, user_email=request.user.email + ) + authorizations = list( + repository.authorizations.filter( + uuid__in=project_organization + ).values_list("uuid", flat=True) + ) if organization: organization_authorization = organization.get_organization_authorization( @@ -321,7 +343,7 @@ def remove_repository_project(self, request, **kwargs): authorizations += list( organization.organization_authorizations.filter( - uuid__in=project_organization.result + uuid__in=project_organization ).values_list("uuid", flat=True) ) @@ -334,11 +356,20 @@ def remove_repository_project(self, request, **kwargs): lambda authorization: str(authorization), authorizations ) - task = celery_app.send_task( - name="remove_authorizations_project", - args=[project_uuid, list(authorizations_uuids), request.user.email], - ) - task.wait() + if settings.USE_GRPC: + task = celery_app.send_task( + name="remove_authorizations_project", + args=[project_uuid, list(authorizations_uuids), request.user.email], + ) + task.wait() + else: + task = ConnectClient().list_authorizations( + project_uuid=project_uuid, user_email=request.user.email + ) + for authorization_uuid in authorizations_uuids: + ConnectClient().remove_authorization( + project_uuid, authorization_uuid, request.user.email + ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -381,14 +412,22 @@ def add_repository_project(self, request, **kwargs): project_uuid = serializer.validated_data.get("project_uuid") - task = celery_app.send_task( - name="get_project_organization", args=[project_uuid] - ) - task.wait() + if settings.USE_GRPC: + task = celery_app.send_task( + name="get_project_organization", args=[project_uuid] + ) + task.wait() - organization_authorization = organization.organization_authorizations.filter( - uuid__in=task.result - ) + organization_authorization = ( + organization.organization_authorizations.filter(uuid__in=task.result) + ) + else: + task = ConnectClient().list_authorizations( + project_uuid=project_uuid, user_email=request.user.email + ) + organization_authorization = ( + organization.organization_authorizations.filter(uuid__in=task) + ) if organization_authorization.exists(): raise ValidationError(_("Repository already added")) @@ -803,12 +842,21 @@ def list_project_organizatiton(self, request, **kwargs): if not project_uuid: raise ValidationError(_("Need to pass 'project_uuid' in query params")) - task = celery_app.send_task( - name="get_project_organization", args=[project_uuid] - ) - task.wait() - - repositories = Repository.objects.filter(authorizations__uuid__in=task.result) + if settings.USE_GRPC: + task = celery_app.send_task( + name="get_project_organization", args=[project_uuid, request.user.email] + ) + task.wait() + repositories = Repository.objects.filter( + authorizations__uuid__in=task.result + ) + else: + authorizations = ConnectClient().list_authorizations( + project_uuid=project_uuid, user_email=request.user.email + ) + repositories = Repository.objects.filter( + authorizations__uuid__in=authorizations + ) serialized_data = ShortRepositorySerializer(repositories, many=True) return Response(serialized_data.data) diff --git a/bothub/api/v2/routers.py b/bothub/api/v2/routers.py index 55e8b818..fbf67a88 100644 --- a/bothub/api/v2/routers.py +++ b/bothub/api/v2/routers.py @@ -66,6 +66,14 @@ RepositoryTranslatorViewSet, ) +from bothub.api.v2.internal.repository.views import InternalRepositoriesViewSet +from bothub.api.v2.internal.organization.views import InternalOrganizationViewSet +from bothub.api.v2.internal.user.views import ( + UserPermissionViewSet, + UserViewSet, + UserLanguageViewSet, +) + class Router(routers.SimpleRouter): routes = [ @@ -233,3 +241,8 @@ def get_lookup_regex(self, viewset, lookup_prefix=""): router.register("org/organization", OrganizationViewSet) router.register("org/profile", OrganizationProfileViewSet) router.register("org/authorizations", OrganizationAuthorizationViewSet) +router.register("internal/organization", InternalOrganizationViewSet) +router.register("internal/repository", InternalRepositoriesViewSet) +router.register("internal/user", UserViewSet) +router.register("internal/user/permission", UserPermissionViewSet) +router.register("internal/user/language", UserLanguageViewSet) diff --git a/bothub/api/v2/tests/test_repository.py b/bothub/api/v2/tests/test_repository.py index 9aa601e6..025753f9 100644 --- a/bothub/api/v2/tests/test_repository.py +++ b/bothub/api/v2/tests/test_repository.py @@ -42,48 +42,11 @@ from bothub.common.models import RepositoryVote from bothub.common.models import RequestRepositoryAuthorization - -def get_valid_mockups(categories): - return [ - { - "name": "Repository 1", - "slug": "repository-1", - "description": "", - "language": languages.LANGUAGE_EN, - "categories": [category.pk for category in categories], - }, - { - "name": "Repository 2", - "description": "", - "language": languages.LANGUAGE_PT, - "categories": [category.pk for category in categories], - }, - ] - - -def get_invalid_mockups(categories): - return [ - { - "name": "", - "slug": "repository-1", - "language": languages.LANGUAGE_EN, - "categories": [category.pk for category in categories], - }, - { - "name": "Repository 3", - "language": "out", - "categories": [category.pk for category in categories], - "is_private": False, - }, - ] - - -def create_repository_from_mockup(owner, categories, **mockup): - r = Repository.objects.create(owner_id=owner.id, **mockup) - r.current_version() - for category in categories: - r.categories.add(category) - return r +from bothub.api.v2.tests.utils import ( + get_valid_mockups, + get_invalid_mockups, + create_repository_from_mockup, +) class CreateRepositoryAPITestCase(TestCase): diff --git a/bothub/api/v2/tests/utils.py b/bothub/api/v2/tests/utils.py index 7ede7eb6..5d1c6901 100644 --- a/bothub/api/v2/tests/utils.py +++ b/bothub/api/v2/tests/utils.py @@ -1,9 +1,58 @@ +from bothub.utils import check_module_permission from rest_framework.authtoken.models import Token from bothub.authentication.models import User +from bothub.common import languages +from bothub.common.models import Repository -def create_user_and_token(nickname="fake"): +def create_user_and_token(nickname="fake", module=False): user = User.objects.create_user("{}@user.com".format(nickname), nickname) + if module is True: + check_module_permission({"can_communicate_internally": module}, user) + user = User.objects.get(email=user.email) token, create = Token.objects.get_or_create(user=user) return (user, token) + + +def get_valid_mockups(categories): + return [ + { + "name": "Repository 1", + "slug": "repository-1", + "description": "", + "language": languages.LANGUAGE_EN, + "categories": [category.pk for category in categories], + }, + { + "name": "Repository 2", + "description": "", + "language": languages.LANGUAGE_PT, + "categories": [category.pk for category in categories], + }, + ] + + +def get_invalid_mockups(categories): + return [ + { + "name": "", + "slug": "repository-1", + "language": languages.LANGUAGE_EN, + "categories": [category.pk for category in categories], + }, + { + "name": "Repository 3", + "language": "out", + "categories": [category.pk for category in categories], + "is_private": False, + }, + ] + + +def create_repository_from_mockup(owner, categories, **mockup): + r = Repository.objects.create(owner_id=owner.id, **mockup) + r.current_version() + for category in categories: + r.categories.add(category) + return r diff --git a/bothub/authentication/authorization.py b/bothub/authentication/authorization.py index ccc89e80..c2e4562c 100644 --- a/bothub/authentication/authorization.py +++ b/bothub/authentication/authorization.py @@ -1,6 +1,10 @@ import logging +import re from django.utils.translation import ugettext_lazy as _ +from bothub.utils import check_module_permission + + from mozilla_django_oidc.auth import OIDCAuthenticationBackend from rest_framework import exceptions from rest_framework.authentication import TokenAuthentication, get_authorization_header @@ -92,16 +96,20 @@ def create_user(self, claims): # Override existing create_user method in OIDCAuthenticationBackend email = claims.get("email") username = self.get_username(claims)[:16] + username = re.sub("[^A-Za-z0-9]+", "", username) user = self.UserModel.objects.create_user(email, username) user.name = claims.get("name", "") user.save() + check_module_permission(claims, user) + return user def update_user(self, user, claims): user.name = claims.get("name", "") user.email = claims.get("email", "") user.save() + check_module_permission(claims, user) return user diff --git a/bothub/common/models.py b/bothub/common/models.py index f9a8793e..05163b18 100644 --- a/bothub/common/models.py +++ b/bothub/common/models.py @@ -906,16 +906,18 @@ def get_specific_version_id(self, repository_version, language=None): def get_user_authorization(self, user): if user.is_anonymous: return RepositoryAuthorization(repository=self) - get, created = RepositoryAuthorization.objects.get_or_create( + repo_auth, created = RepositoryAuthorization.objects.get_or_create( user=user.repository_owner, repository=self ) if self.owner.is_organization: - org_role = self.owner.organization.get_organization_authorization(user).role - if get.role != org_role and get.role == 0: - get.role = org_role - get.save() - - return get + org_auth = self.owner.organization.get_organization_authorization(user) + + # Excluding ROLE_TRANSLATE as it does not correspond to the same role in the client app (connect). + # todo: update this conditional with corresponding role rule + if repo_auth.role < org_auth.role and org_auth.role < RepositoryAuthorization.ROLE_TRANSLATE: + repo_auth.role = org_auth.role + repo_auth.save(update_fields=['role']) + return repo_auth def get_absolute_url(self): return "{}dashboard/{}/{}/".format( diff --git a/bothub/common/tasks.py b/bothub/common/tasks.py index 59d29e77..1660e7f0 100644 --- a/bothub/common/tasks.py +++ b/bothub/common/tasks.py @@ -12,7 +12,6 @@ from django_elasticsearch_dsl.registries import registry from bothub import translate -from bothub.api.grpc.connect_grpc_client import ConnectGRPCClient from bothub.celery import app from bothub.common.models import ( RepositoryQueueTask, @@ -40,6 +39,14 @@ ) +if settings.USE_GRPC: + from bothub.api.grpc.connect_grpc_client import ConnectGRPCClient as ConnectClient +else: + from bothub.api.v2.internal.connect_rest_client import ( + ConnectRESTClient as ConnectClient, + ) + + @app.task(name="es_handle_save") def handle_save(pk, app_label, model_name): sender = apps.get_model(app_label, model_name) @@ -544,9 +551,16 @@ def evaluate_crossvalidation(data, authorization_token): # pragma: no cover @app.task(name="get_project_organization") -def get_project_organization(project_uuid: str): # pragma: no cover - grpc_client = ConnectGRPCClient() - authorizations = grpc_client.list_authorizations(project_uuid=project_uuid) +def get_project_organization( + project_uuid: str, user_email: str = "" +): # pragma: no cover + grpc_client = ConnectClient() + if settings.USE_GRPC: + authorizations = grpc_client.list_authorizations(project_uuid=project_uuid) + else: + authorizations = grpc_client.list_authorizations( + project_uuid=project_uuid, user_email=user_email + ) return authorizations @@ -554,13 +568,13 @@ def get_project_organization(project_uuid: str): # pragma: no cover def remove_authorizations_project( project_uuid: str, authorizations_uuids: list, user_email: str ): - grpc_client = ConnectGRPCClient() + grpc_client = ConnectClient() for authorization_uuid in authorizations_uuids: grpc_client.remove_authorization(project_uuid, authorization_uuid, user_email) @app.task(name="create_repository_project") def create_repository_project(**kwargs): - grpc_client = ConnectGRPCClient() + grpc_client = ConnectClient() grpc_client.create_classifier(**kwargs) return kwargs diff --git a/bothub/common/tests.py b/bothub/common/tests.py index 8b72ba04..a9bfe505 100644 --- a/bothub/common/tests.py +++ b/bothub/common/tests.py @@ -15,6 +15,8 @@ RepositoryQueueTask, QAKnowledgeBase, QAtext, + Organization, + OrganizationAuthorization ) from .models import RepositoryAuthorization from .models import RepositoryEntity @@ -544,6 +546,7 @@ class RepositoryAuthorizationTestCase(TestCase): def setUp(self): self.owner = User.objects.create_user("owner@user.com", "owner") self.user = User.objects.create_user("fake@user.com", "user") + self.collaborator = User.objects.create_user("colaborator@user.com", "collaborator") self.repository = Repository.objects.create( owner=self.owner.repository_owner, name="Test", slug="test" @@ -554,6 +557,12 @@ def setUp(self): slug="private", is_private=True, ) + self.organization = Organization.objects.create(name="Weni") + self.organization_repository = Repository.objects.create( + owner=self.organization, + name="Organization Repository", + slug="organization_repository" + ) def test_admin_level(self): authorization = self.repository.get_user_authorization(self.owner) @@ -682,6 +691,50 @@ def test_role_contributor_can_contribute(self): authorization_user.save() self.assertTrue(authorization_user.can_contribute) + def test_organization_auth_over_repository_auth(self): + """ + Tests that a User's authorization role is of the highest level possible in a Repository, + either using the RepositoryAuthorization or the OrganizationAuthorization. + The expected behavior is that the organization's authorization role should be passed to the repository's authorization for that user. + """ + + # The role that the user should inherit from the organization authorization inside the repository. + initial_organization_role = OrganizationAuthorization.ROLE_ADMIN + + # Set user's role to a low level at the Repository + collaborator_repository_auth, created = RepositoryAuthorization.objects.get_or_create( + user=self.collaborator, repository=self.organization_repository, role=RepositoryAuthorization.ROLE_USER + ) + # Set user's role to a high level at the Organization + collaborator_organization_auth = self.organization.organization_authorizations.create( + user=self.collaborator, role=initial_organization_role + ) + + # Validate that their access level corresponds to their role in the Organization and not the Repository, as it is higher at this point. + user_authorization = self.organization_repository.get_user_authorization(self.collaborator) + self.assertEqual(user_authorization.role, collaborator_organization_auth.role) + + # Lower their level inside the Organization + collaborator_organization_auth.role = OrganizationAuthorization.ROLE_NOT_SETTED + collaborator_organization_auth.save() + + # Validate that the repository authorization level was updated. + collaborator_repository_auth.refresh_from_db() + self.assertEqual(collaborator_repository_auth.role, initial_organization_role) + + # Validate that the user's level is now the Repository's and not the Organization's, as it is higher. + user_authorization = self.organization_repository.get_user_authorization(self.collaborator) + self.assertEqual(user_authorization.role, collaborator_repository_auth.role) + + # Verify that org auth with (role >= 4) will not update the repository's authorization + + # Set user's role to ROLE_TRANSLATE level at the Organization + collaborator_organization_auth.role = OrganizationAuthorization.ROLE_TRANSLATE + collaborator_organization_auth.save() + + user_authorization = self.organization_repository.get_user_authorization(self.collaborator) + self.assertEqual(user_authorization.role, collaborator_repository_auth.role) + class RepositoryVersionTrainingTestCase(TestCase): def setUp(self): diff --git a/bothub/settings.py b/bothub/settings.py index 0f0b01fc..a4ee2ac6 100644 --- a/bothub/settings.py +++ b/bothub/settings.py @@ -75,6 +75,8 @@ SECRET_KEY_CHECK_LEGACY_USER=(str, None), CONNECT_GRPC_SERVER_URL=(str, "localhost:8002"), CONNECT_CERTIFICATE_GRPC_CRT=(str, None), + USE_GRPC=(bool, False), + CONNECT_API_URL=(str, ""), REPOSITORY_RESTRICT_ACCESS_NLP_LOGS=(list, []), REPOSITORY_BLOCK_USER_LOGS=(list, []), REPOSITORY_KNOWLEDGE_BASE_DESCRIPTION_LIMIT=(int, 450), @@ -92,6 +94,17 @@ ELASTICSEARCH_LOGS_DELETE_AGE=(str, "90d"), ELASTICSEARCH_LOGS_ROLLOVER_AGE=(str, "1d"), ELASTICSEARCH_TIMESTAMP_PIPELINE_FIELD=(str, "created_at"), + CSP_DEFAULT_SRC=(tuple, "CSP_DEFAULT_SRC"), + CSP_FRAME_ANCESTORS=(tuple, "CSP_FRAME_ANCESTORS"), + CSP_FONT_SRC=(tuple, "CSP_FONT_SRC"), + CSP_STYLE_SRC=(tuple, "CSP_STYLE_SRC"), + CSP_STYLE_SRC_ELEM=(tuple, "CSP_STYLE_SRC_ELEM"), + CSP_SCRIPT_SRC=(tuple, "CSP_SCRIPT_SRC"), + CSP_SCRIPT_SRC_ELEM=(tuple, "CSP_SCRIPT_SRC_ELEM"), + CSP_FRAME_SRC=(tuple, "CSP_FRAME_SRC"), + CSP_CONNECT_SRC=(tuple, "CSP_CONNECT_SRC"), + CSP_WORKER_SRC=(tuple, "CSP_WORKER_SRC"), + CSP_IMG_SRC=(tuple, "CSP_IMG_SRC"), ) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -144,12 +157,12 @@ "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.locale.LocaleMiddleware", + "csp.middleware.CSPMiddleware", "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", "bothub.api.v2.middleware.UserLanguageMiddleware", ] @@ -299,6 +312,30 @@ CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE") +# CSP headers + +DEFAULT_CSP_SETTINGS = ("'self'",) +DEFAULT_CSP_WENI_SETTINGS = DEFAULT_CSP_SETTINGS + ("*.weni.ai",) + +CSP_DEFAULT_SRC = env.tuple("CSP_DEFAULT_SRC", default=DEFAULT_CSP_SETTINGS) +CSP_FRAME_ANCESTORS = env.tuple( + "CSP_FRAME_ANCESTORS", default=DEFAULT_CSP_WENI_SETTINGS +) +CSP_FONT_SRC = env.tuple("CSP_FONT_SRC", default=CSP_DEFAULT_SRC) +CSP_STYLE_SRC = env.tuple( + "CSP_STYLE_SRC", default=DEFAULT_CSP_SETTINGS + ("'unsafe-inline'", "'unsafe-eval'") +) +CSP_STYLE_SRC_ELEM = env.tuple("CSP_STYLE_SRC_ELEM", default=CSP_STYLE_SRC) +CSP_SCRIPT_SRC = env.tuple("CSP_SCRIPT_SRC", default=CSP_STYLE_SRC) +CSP_SCRIPT_SRC_ELEM = env.tuple("CSP_SCRIPT_SRC_ELEM", default=CSP_SCRIPT_SRC) +CSP_FRAME_SRC = env.tuple("CSP_FRAME_SRC", default=CSP_DEFAULT_SRC) +CSP_CONNECT_SRC = env.tuple("CSP_CONNECT_SRC", default=CSP_DEFAULT_SRC) +CSP_WORKER_SRC = env.tuple( + "CSP_WORKER_SRC", default=DEFAULT_CSP_WENI_SETTINGS + ("blob:", "data:") +) +CSP_IMG_SRC = env.tuple("CSP_IMG_SRC", default=CSP_WORKER_SRC) + + # Logging LOGGING = DEFAULT_LOGGING @@ -531,6 +568,10 @@ CONNECT_CERTIFICATE_GRPC_CRT = env.str("CONNECT_CERTIFICATE_GRPC_CRT") +USE_GRPC = env.bool("USE_GRPC", default=False) + +CONNECT_API_URL = env.str("CONNECT_API_URL", default="https://api.dev.cloud.weni.ai") + # ElasticSearch ELASTICSEARCH_DSL = { "default": {"hosts": env.str("ELASTICSEARCH_DSL", default="es:9200")} diff --git a/bothub/utils.py b/bothub/utils.py index 38949d12..40e36dcd 100644 --- a/bothub/utils.py +++ b/bothub/utils.py @@ -20,6 +20,7 @@ from rest_framework import status from rest_framework.exceptions import APIException, ValidationError + entity_regex = re.compile( r"\[(?P[^\]]+)" r"\]\((?P[^:)]*?)" r"(?:\:(?P[^)]+))?\)" ) @@ -375,6 +376,17 @@ def get_organization(request, organization_id: int): ) +def get_user_and_organization(user_email: str, organization_id: int): + from bothub.authentication.models import User + from bothub.common.models import Organization + + org = Organization.objects.get(pk=organization_id) + user, created = User.objects.get_or_create( + email=user_email, defaults={"nickname": user_email} + ) + return user, org + + class TimeBasedDocument(Document): def save(self, action="create", **kwargs): return super().save(action=action, **kwargs) @@ -462,3 +474,43 @@ def filter_has_invalid_entities(self, queryset, name, value): return filter_validate_entities(queryset, value).exclude( original_entities_count=F("entities_count") ) + + +def check_module_permission(claims, user): + from django.contrib.auth.models import Permission + from django.contrib.contenttypes.models import ContentType + from bothub.common.models import User + + if claims.get("can_communicate_internally", False): + content_type = ContentType.objects.get_for_model(User) + permission, created = Permission.objects.get_or_create( + codename="can_communicate_internally", + name="can communicate internally", + content_type=content_type, + ) + if not user.has_perm("authentication.can_communicate_internally"): + user.user_permissions.add(permission) + return True + return False + + +internal_serializer_fields = [ + "uuid", + "name", + "slug", + "description", + "is_private", + "created_at", + "language", + "owner", + "algorithm", + "use_competing_intents", + "use_name_entities", + "use_analyze_char", + "owner__nickname", + "intents", + "categories", + "available_languages", + "categories_list", + "repository_type", +] diff --git a/poetry.lock b/poetry.lock index d67a0a89..6199b760 100644 --- a/poetry.lock +++ b/poetry.lock @@ -445,6 +445,21 @@ python-versions = ">=3.6" [package.dependencies] Django = ">=2.2" +[[package]] +name = "django-csp" +version = "3.7" +description = "Django Content Security Policy support." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +Django = ">=1.8" + +[package.extras] +jinja2 = ["jinja2 (>=2.9.6)"] +tests = ["pytest (<4.0)", "pytest-django", "pytest-flakes (==1.0.1)", "pytest-pep8 (==1.0.6)", "pep8 (==1.4.6)", "mock (==1.0.1)", "six (==1.12.0)", "jinja2 (>=2.9.6)"] + [[package]] name = "django-elasticsearch-dsl" version = "7.2.2" @@ -1711,7 +1726,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "~=3.6.15" -content-hash = "dd6bfc5c1373deba58e4e85a9cb3114b91f7d5d5a33db1ac48afee53bbe395f8" +content-hash = "31ccdac8c205f34af79715c1d090792b5496b461d94085bc7715f635cfbf4dfa" [metadata.files] amqp = [ @@ -1970,6 +1985,10 @@ django-cors-headers = [ {file = "django-cors-headers-3.7.0.tar.gz", hash = "sha256:96069c4aaacace786a34ee7894ff680780ec2644e4268b31181044410fecd12e"}, {file = "django_cors_headers-3.7.0-py3-none-any.whl", hash = "sha256:1ac2b1213de75a251e2ba04448da15f99bcfcbe164288ae6b5ff929dc49b372f"}, ] +django-csp = [ + {file = "django_csp-3.7-py2.py3-none-any.whl", hash = "sha256:01443a07723f9a479d498bd7bb63571aaa771e690f64bde515db6cdb76e8041a"}, + {file = "django_csp-3.7.tar.gz", hash = "sha256:01eda02ad3f10261c74131cdc0b5a6a62b7c7ad4fd017fbefb7a14776e0a9727"}, +] django-elasticsearch-dsl = [ {file = "django-elasticsearch-dsl-7.2.2.tar.gz", hash = "sha256:811d3909b3387fd55c19d9bbcf0e9a9b234f085df3f8422d59e7519a5f733e0e"}, {file = "django_elasticsearch_dsl-7.2.2-py2.py3-none-any.whl", hash = "sha256:3c58a254a6318b169eb904d41d802924b99ea8e53ddc2c596ebba90506cf47fa"}, diff --git a/pyproject.toml b/pyproject.toml index c6d48f5e..0abed417 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ psycopg2-binary = "~=2.9.1" weni-protobuffers = "~=1.2.1" black = "21.7b0" Pillow = "~=8.4.0" +django-csp = "^3.7" [tool.poetry.dev-dependencies] flake8 = "~=4.0.0"