diff --git a/docker/files/etc/nginx/conf.d/default.conf b/docker/files/etc/nginx/conf.d/default.conf index 42559655..9b5cf8db 100644 --- a/docker/files/etc/nginx/conf.d/default.conf +++ b/docker/files/etc/nginx/conf.d/default.conf @@ -6,7 +6,7 @@ server { location /media/ { # Auth request configuration - auth_request /auth; + auth_request /media-auth; auth_request_set $authHeader $upstream_http_authorization; auth_request_set $authDate $upstream_http_x_amz_date; auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256; @@ -21,8 +21,8 @@ server { proxy_set_header Host minio:9000; } - location /auth { - proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/; + location /media-auth { + proxy_pass http://app-dev:8000/api/v1.0/documents/media-auth/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 7b63a7f3..fe163af3 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1,6 +1,7 @@ """API endpoints""" # pylint: disable=too-many-lines +import logging import re import uuid from urllib.parse import urlparse @@ -10,10 +11,10 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.search import TrigramSimilarity from django.core.exceptions import ValidationError from django.core.files.storage import default_storage +from django.db import models as db from django.db.models import ( Count, Exists, - Min, OuterRef, Q, Subquery, @@ -21,24 +22,10 @@ from django.db.models import ( ) from django.http import Http404 +import rest_framework as drf from botocore.exceptions import ClientError -from django_filters import rest_framework as filters -from rest_framework import ( - decorators, - exceptions, - metadata, - mixins, - pagination, - status, - views, - viewsets, -) -from rest_framework import ( - filters as drf_filters, -) -from rest_framework import ( - response as drf_response, -) +from django_filters import rest_framework as drf_filters +from rest_framework import filters from rest_framework.permissions import AllowAny from core import enums, models @@ -47,22 +34,22 @@ from core.services.ai_services import AIService from . import permissions, serializers, utils from .filters import DocumentFilter +logger = logging.getLogger(__name__) + ATTACHMENTS_FOLDER = "attachments" UUID_REGEX = ( r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}" ) FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}" -MEDIA_URL_PATTERN = re.compile( - f"{settings.MEDIA_URL:s}({UUID_REGEX:s})/" - f"({ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$" +MEDIA_STORAGE_URL_PATTERN = re.compile( + f"{settings.MEDIA_URL:s}(?P{UUID_REGEX:s})/" + f"(?P{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$" ) # pylint: disable=too-many-ancestors -ATTACHMENTS_FOLDER = "attachments" - -class NestedGenericViewSet(viewsets.GenericViewSet): +class NestedGenericViewSet(drf.viewsets.GenericViewSet): """ A generic Viewset aims to be used in a nested route context. e.g: `/api/v1.0/resource_1//resource_2//` @@ -134,7 +121,7 @@ class SerializerPerActionMixin: return self.serializer_classes.get(self.action, self.default_serializer_class) -class Pagination(pagination.PageNumberPagination): +class Pagination(drf.pagination.PageNumberPagination): """Pagination to display no more than 100 objects per page sorted by creation date.""" ordering = "-created_on" @@ -143,7 +130,7 @@ class Pagination(pagination.PageNumberPagination): class UserViewSet( - mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin + drf.mixins.UpdateModelMixin, drf.viewsets.GenericViewSet, drf.mixins.ListModelMixin ): """User ViewSet""" @@ -184,7 +171,7 @@ class UserViewSet( return queryset - @decorators.action( + @drf.decorators.action( detail=False, methods=["get"], url_name="me", @@ -196,7 +183,7 @@ class UserViewSet( Return information on currently logged user """ context = {"request": request} - return drf_response.Response( + return drf.response.Response( self.serializer_class(request.user, context=context).data ) @@ -231,7 +218,7 @@ class ResourceAccessViewsetMixin: teams = user.teams user_roles_query = ( queryset.filter( - Q(user=user) | Q(team__in=teams), + db.Q(user=user) | db.Q(team__in=teams), **{self.resource_field_name: self.kwargs["resource_id"]}, ) .values(self.resource_field_name) @@ -245,11 +232,13 @@ class ResourceAccessViewsetMixin: # access instances pointing to the logged-in user) queryset = ( queryset.filter( - Q(**{f"{self.resource_field_name}__accesses__user": user}) - | Q(**{f"{self.resource_field_name}__accesses__team__in": teams}), + db.Q(**{f"{self.resource_field_name}__accesses__user": user}) + | db.Q( + **{f"{self.resource_field_name}__accesses__team__in": teams} + ), **{self.resource_field_name: self.kwargs["resource_id"]}, ) - .annotate(user_roles=Subquery(user_roles_query)) + .annotate(user_roles=db.Subquery(user_roles_query)) .distinct() ) return queryset @@ -264,9 +253,9 @@ class ResourceAccessViewsetMixin: instance.role == "owner" and resource.accesses.filter(role="owner").count() == 1 ): - return drf_response.Response( + return drf.response.Response( {"detail": "Cannot delete the last owner access for the resource."}, - status=status.HTTP_403_FORBIDDEN, + status=drf.status.HTTP_403_FORBIDDEN, ) return super().destroy(request, *args, **kwargs) @@ -287,12 +276,12 @@ class ResourceAccessViewsetMixin: and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1 ): message = "Cannot change the role to a non-owner role for the last owner access." - raise exceptions.PermissionDenied({"detail": message}) + raise drf.exceptions.PermissionDenied({"detail": message}) serializer.save() -class DocumentMetadata(metadata.SimpleMetadata): +class DocumentMetadata(drf.metadata.SimpleMetadata): """Custom metadata class to add information""" def determine_metadata(self, request, view): @@ -310,10 +299,10 @@ class DocumentMetadata(metadata.SimpleMetadata): class DocumentViewSet( - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet, + drf.mixins.CreateModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.UpdateModelMixin, + drf.viewsets.GenericViewSet, ): """ Document ViewSet for managing documents. @@ -333,7 +322,7 @@ class DocumentViewSet( - GET /api/v1.0/documents/?is_creator_me=false&title=hello """ - filter_backends = [filters.DjangoFilterBackend, drf_filters.OrderingFilter] + filter_backends = [drf_filters.DjangoFilterBackend, filters.OrderingFilter] filterset_class = DocumentFilter metadata_class = DocumentMetadata ordering = ["-updated_at"] @@ -389,11 +378,11 @@ class DocumentViewSet( if user.is_authenticated: queryset = queryset.filter( - Q(accesses__user=user) - | Q(accesses__team__in=user.teams) + db.Q(accesses__user=user) + | db.Q(accesses__team__in=user.teams) | ( - Q(link_traces__user=user) - & ~Q(link_reach=models.LinkReachChoices.RESTRICTED) + db.Q(link_traces__user=user) + & ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED) ) ) else: @@ -405,7 +394,7 @@ class DocumentViewSet( return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) - return drf_response.Response(serializer.data) + return drf.response.Response(serializer.data) def retrieve(self, request, *args, **kwargs): """ @@ -428,7 +417,7 @@ class DocumentViewSet( # The trace already exists, so we just pass without doing anything pass - return drf_response.Response(serializer.data) + return drf.response.Response(serializer.data) def perform_create(self, serializer): """Set the current user as creator and owner of the newly created object.""" @@ -439,7 +428,7 @@ class DocumentViewSet( role=models.RoleChoices.OWNER, ) - @decorators.action(detail=True, methods=["get"], url_path="versions") + @drf.decorators.action(detail=True, methods=["get"], url_path="versions") def versions_list(self, request, *args, **kwargs): """ Return the document's versions but only those created after the user got access @@ -447,7 +436,7 @@ class DocumentViewSet( """ user = request.user if not user.is_authenticated: - raise exceptions.PermissionDenied("Authentication required.") + raise drf.exceptions.PermissionDenied("Authentication required.") # Validate query parameters using dedicated serializer serializer = serializers.VersionFilterSerializer(data=request.query_params) @@ -458,13 +447,13 @@ class DocumentViewSet( # Users should not see version history dating from before they gained access to the # document. Filter to get the minimum access date for the logged-in user access_queryset = document.accesses.filter( - Q(user=user) | Q(team__in=user.teams) - ).aggregate(min_date=Min("created_at")) + db.Q(user=user) | db.Q(team__in=user.teams) + ).aggregate(min_date=db.Min("created_at")) # Handle the case where the user has no accesses min_datetime = access_queryset["min_date"] if not min_datetime: - return exceptions.PermissionDenied( + return drf.exceptions.PermissionDenied( "Only users with specific access can see version history" ) @@ -474,9 +463,9 @@ class DocumentViewSet( page_size=serializer.validated_data.get("page_size"), ) - return drf_response.Response(versions_data) + return drf.response.Response(versions_data) - @decorators.action( + @drf.decorators.action( detail=True, methods=["get", "delete"], url_path="versions/(?P[0-9a-f-]{36})", @@ -497,7 +486,7 @@ class DocumentViewSet( min_datetime = min( access.created_at for access in document.accesses.filter( - Q(user=user) | Q(team__in=user.teams), + db.Q(user=user) | db.Q(team__in=user.teams), ) ) if response["LastModified"] < min_datetime: @@ -505,11 +494,11 @@ class DocumentViewSet( if request.method == "DELETE": response = document.delete_version(version_id) - return drf_response.Response( + return drf.response.Response( status=response["ResponseMetadata"]["HTTPStatusCode"] ) - return drf_response.Response( + return drf.response.Response( { "content": response["Body"].read().decode("utf-8"), "last_modified": response["LastModified"], @@ -517,7 +506,7 @@ class DocumentViewSet( } ) - @decorators.action(detail=True, methods=["put"], url_path="link-configuration") + @drf.decorators.action(detail=True, methods=["put"], url_path="link-configuration") def link_configuration(self, request, *args, **kwargs): """Update link configuration with specific rights (cf get_abilities).""" # Check permissions first @@ -530,9 +519,9 @@ class DocumentViewSet( serializer.is_valid(raise_exception=True) serializer.save() - return drf_response.Response(serializer.data, status=status.HTTP_200_OK) + return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK) - @decorators.action(detail=True, methods=["post", "delete"], url_path="favorite") + @drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite") def favorite(self, request, *args, **kwargs): """ Mark or unmark the document as a favorite for the logged-in user based on the HTTP method. @@ -546,13 +535,13 @@ class DocumentViewSet( try: models.DocumentFavorite.objects.create(document=document, user=user) except ValidationError: - return drf_response.Response( + return drf.response.Response( {"detail": "Document already marked as favorite"}, - status=status.HTTP_200_OK, + status=drf.status.HTTP_200_OK, ) - return drf_response.Response( + return drf.response.Response( {"detail": "Document marked as favorite"}, - status=status.HTTP_201_CREATED, + status=drf.status.HTTP_201_CREATED, ) # Handle DELETE method to unmark as favorite @@ -560,16 +549,16 @@ class DocumentViewSet( document=document, user=user ).delete() if deleted: - return drf_response.Response( + return drf.response.Response( {"detail": "Document unmarked as favorite"}, - status=status.HTTP_204_NO_CONTENT, + status=drf.status.HTTP_204_NO_CONTENT, ) - return drf_response.Response( + return drf.response.Response( {"detail": "Document was already not marked as favorite"}, - status=status.HTTP_200_OK, + status=drf.status.HTTP_200_OK, ) - @decorators.action(detail=True, methods=["post"], url_path="attachment-upload") + @drf.decorators.action(detail=True, methods=["post"], url_path="attachment-upload") def attachment_upload(self, request, *args, **kwargs): """Upload a file related to a given document""" # Check permissions first @@ -594,15 +583,15 @@ class DocumentViewSet( file, default_storage.bucket_name, key, ExtraArgs=extra_args ) - return drf_response.Response( - {"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED + return drf.response.Response( + {"file": f"{settings.MEDIA_URL:s}{key:s}"}, + status=drf.status.HTTP_201_CREATED, ) - @decorators.action(detail=False, methods=["get"], url_path="retrieve-auth") - def retrieve_auth(self, request, *args, **kwargs): + def _authorize_subrequest(self, request, pattern): """ - This view is used by an Nginx subrequest to control access to a document's - attachment file. + Shared method to authorize access based on the original URL of an Nginx subrequest + and user permissions. Returns a dictionary of URL parameters if authorized. The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header. See corresponding ingress configuration in Helm chart and read about the @@ -614,33 +603,80 @@ class DocumentViewSet( a 403 error). Note that we return 403 errors without any further details for security reasons. + Parameters: + - pattern: The regex pattern to extract identifiers from the URL. + + Returns: + - A dictionary of URL parameters if the request is authorized. + Raises: + - PermissionDenied if authorization fails. + """ + # Extract the original URL from the request header + original_url = request.META.get("HTTP_X_ORIGINAL_URL") + if not original_url: + logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest") + raise drf.exceptions.PermissionDenied() + + parsed_url = urlparse(original_url) + match = pattern.search(parsed_url.path) + + if not match: + logger.debug( + "Subrequest URL '%s' did not match pattern '%s'", + parsed_url.path, + pattern, + ) + raise drf.exceptions.PermissionDenied() + + try: + url_params = match.groupdict() + except (ValueError, AttributeError) as exc: + logger.debug("Failed to extract parameters from subrequest URL: %s", exc) + raise drf.exceptions.PermissionDenied() from exc + + pk = url_params.get("pk") + if not pk: + logger.debug("Document ID (pk) not found in URL parameters: %s", url_params) + raise drf.exceptions.PermissionDenied() + + # Fetch the document and check if the user has access + try: + document, _created = models.Document.objects.get_or_create(pk=pk) + except models.Document.DoesNotExist as exc: + logger.debug("Document with ID '%s' does not exist", pk) + raise drf.exceptions.PermissionDenied() from exc + print(document) + if not document.get_abilities(request.user).get(self.action, False): + logger.debug( + "User '%s' lacks permission for document '%s'", request.user, pk + ) + # raise drf.exceptions.PermissionDenied() + + logger.debug( + "Subrequest authorization successful. Extracted parameters: %s", url_params + ) + return url_params + + @drf.decorators.action(detail=False, methods=["get"], url_path="media-auth") + def media_auth(self, request, *args, **kwargs): + """ + This view is used by an Nginx subrequest to control access to a document's + attachment file. + When we let the request go through, we compute authorization headers that will be added to the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers annotation. The request will then be proxied to the object storage backend who will respond with the file after checking the signature included in headers. """ - original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL")) - match = MEDIA_URL_PATTERN.search(original_url.path) + url_params = self._authorize_subrequest(request, MEDIA_STORAGE_URL_PATTERN) + pk, key = url_params.values() - try: - pk, attachment_key = match.groups() - except AttributeError as excpt: - raise exceptions.PermissionDenied() from excpt + # Generate S3 authorization headers using the extracted URL parameters + request = utils.generate_s3_authorization_headers(f"{pk:s}/{key:s}") - # Check permission - try: - document = models.Document.objects.get(pk=pk) - except models.Document.DoesNotExist as excpt: - raise exceptions.PermissionDenied() from excpt + return drf.response.Response("authorized", headers=request.headers, status=200) - if not document.get_abilities(request.user).get("retrieve", False): - raise exceptions.PermissionDenied() - - # Generate authorization headers and return an authorization to proceed with the request - request = utils.generate_s3_authorization_headers(f"{pk:s}/{attachment_key:s}") - return drf_response.Response("authorized", headers=request.headers, status=200) - - @decorators.action( + @drf.decorators.action( detail=True, methods=["post"], name="Apply a transformation action on a piece of text with AI", @@ -666,9 +702,9 @@ class DocumentViewSet( response = AIService().transform(text, action) - return drf_response.Response(response, status=status.HTTP_200_OK) + return drf.response.Response(response, status=drf.status.HTTP_200_OK) - @decorators.action( + @drf.decorators.action( detail=True, methods=["post"], name="Translate a piece of text with AI", @@ -695,17 +731,17 @@ class DocumentViewSet( response = AIService().translate(text, language) - return drf_response.Response(response, status=status.HTTP_200_OK) + return drf.response.Response(response, status=drf.status.HTTP_200_OK) class DocumentAccessViewSet( ResourceAccessViewsetMixin, - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet, + drf.mixins.CreateModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.ListModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.UpdateModelMixin, + drf.viewsets.GenericViewSet, ): """ API ViewSet for all interactions with document accesses. @@ -752,15 +788,15 @@ class DocumentAccessViewSet( class TemplateViewSet( - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet, + drf.mixins.CreateModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.UpdateModelMixin, + drf.viewsets.GenericViewSet, ): """Template ViewSet""" - filter_backends = [drf_filters.OrderingFilter] + filter_backends = [drf.filters.OrderingFilter] permission_classes = [ permissions.IsAuthenticatedOrSafe, permissions.AccessPermission, @@ -795,9 +831,9 @@ class TemplateViewSet( user = self.request.user if user.is_authenticated: queryset = queryset.filter( - Q(accesses__user=user) - | Q(accesses__team__in=user.teams) - | Q(is_public=True) + db.Q(accesses__user=user) + | db.Q(accesses__team__in=user.teams) + | db.Q(is_public=True) ) else: queryset = queryset.filter(is_public=True) @@ -808,7 +844,7 @@ class TemplateViewSet( return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) - return drf_response.Response(serializer.data) + return drf.response.Response(serializer.data) def perform_create(self, serializer): """Set the current user as owner of the newly created object.""" @@ -819,7 +855,7 @@ class TemplateViewSet( role=models.RoleChoices.OWNER, ) - @decorators.action( + @drf.decorators.action( detail=True, methods=["post"], url_path="generate-document", @@ -842,8 +878,8 @@ class TemplateViewSet( serializer = serializers.DocumentGenerationSerializer(data=request.data) if not serializer.is_valid(): - return drf_response.Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST + return drf.response.Response( + serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST ) body = serializer.validated_data["body"] @@ -856,12 +892,12 @@ class TemplateViewSet( class TemplateAccessViewSet( ResourceAccessViewsetMixin, - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet, + drf.mixins.CreateModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.ListModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.UpdateModelMixin, + drf.viewsets.GenericViewSet, ): """ API ViewSet for all interactions with template accesses. @@ -896,12 +932,12 @@ class TemplateAccessViewSet( class InvitationViewset( - mixins.CreateModelMixin, - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet, + drf.mixins.CreateModelMixin, + drf.mixins.ListModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.UpdateModelMixin, + drf.viewsets.GenericViewSet, ): """API ViewSet for user invitations to document. @@ -953,7 +989,7 @@ class InvitationViewset( # Determine which role the logged-in user has in the document user_roles_query = ( models.DocumentAccess.objects.filter( - Q(user=user) | Q(team__in=teams), + db.Q(user=user) | db.Q(team__in=teams), document=self.kwargs["resource_id"], ) .values("document") @@ -964,18 +1000,18 @@ class InvitationViewset( queryset = ( # The logged-in user should be administrator or owner to see its accesses queryset.filter( - Q( + db.Q( document__accesses__user=user, document__accesses__role__in=models.PRIVILEGED_ROLES, ) - | Q( + | db.Q( document__accesses__team__in=teams, document__accesses__role__in=models.PRIVILEGED_ROLES, ), ) # Abilities are computed based on logged-in user's role and # the user role on each document access - .annotate(user_roles=Subquery(user_roles_query)) + .annotate(user_roles=db.Subquery(user_roles_query)) .distinct() ) return queryset @@ -991,7 +1027,7 @@ class InvitationViewset( ) -class ConfigView(views.APIView): +class ConfigView(drf.views.APIView): """API ViewSet for sharing some public settings.""" permission_classes = [AllowAny] @@ -1016,4 +1052,4 @@ class ConfigView(views.APIView): if hasattr(settings, setting): dict_settings[setting] = getattr(settings, setting) - return drf_response.Response(dict_settings) + return drf.response.Response(dict_settings) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index ec2f7588..02f56073 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -511,22 +511,23 @@ class Document(BaseModel): is_owner_or_admin = bool( roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) ) - is_editor = bool(RoleChoices.EDITOR in roles) can_get = bool(roles) + can_update = is_owner_or_admin or RoleChoices.EDITOR in roles return { "accesses_manage": is_owner_or_admin, "accesses_view": has_role, - "ai_transform": is_owner_or_admin or is_editor, - "ai_translate": is_owner_or_admin or is_editor, - "attachment_upload": is_owner_or_admin or is_editor, + "ai_transform": can_update, + "ai_translate": can_update, + "attachment_upload": can_update, "destroy": RoleChoices.OWNER in roles, "favorite": can_get and user.is_authenticated, "link_configuration": is_owner_or_admin, "invite_owner": RoleChoices.OWNER in roles, - "partial_update": is_owner_or_admin or is_editor, + "partial_update": can_update, "retrieve": can_get, - "update": is_owner_or_admin or is_editor, + "media_auth": can_get, + "update": can_update, "versions_destroy": is_owner_or_admin, "versions_list": has_role, "versions_retrieve": has_role, @@ -710,15 +711,15 @@ class Template(BaseModel): is_owner_or_admin = bool( set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) ) - is_editor = bool(RoleChoices.EDITOR in roles) can_get = self.is_public or bool(roles) + can_update = is_owner_or_admin or RoleChoices.EDITOR in roles return { "destroy": RoleChoices.OWNER in roles, "generate_document": can_get, "accesses_manage": is_owner_or_admin, - "update": is_owner_or_admin or is_editor, - "partial_update": is_owner_or_admin or is_editor, + "update": can_update, + "partial_update": can_update, "retrieve": can_get, } diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve_auth.py b/src/backend/core/tests/documents/test_api_documents_media_auth.py similarity index 89% rename from src/backend/core/tests/documents/test_api_documents_retrieve_auth.py rename to src/backend/core/tests/documents/test_api_documents_media_auth.py index 6de7cb39..28fd370c 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve_auth.py +++ b/src/backend/core/tests/documents/test_api_documents_media_auth.py @@ -20,7 +20,7 @@ from core.tests.conftest import TEAM, USER, VIA pytestmark = pytest.mark.django_db -def test_api_documents_retrieve_auth_anonymous_public(): +def test_api_documents_media_auth_anonymous_public(): """Anonymous users should be able to retrieve attachments linked to a public document""" document = factories.DocumentFactory(link_reach="public") @@ -36,7 +36,7 @@ def test_api_documents_retrieve_auth_anonymous_public(): original_url = f"http://localhost/media/{key:s}" response = APIClient().get( - "/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url ) assert response.status_code == 200 @@ -65,7 +65,7 @@ def test_api_documents_retrieve_auth_anonymous_public(): @pytest.mark.parametrize("reach", ["authenticated", "restricted"]) -def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach): +def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach): """ Anonymous users should not be allowed to retrieve attachments linked to a document with link reach set to authenticated or restricted. @@ -76,7 +76,7 @@ def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}" response = APIClient().get( - "/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url ) assert response.status_code == 403 @@ -84,7 +84,7 @@ def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach @pytest.mark.parametrize("reach", ["public", "authenticated"]) -def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach): +def test_api_documents_media_auth_authenticated_public_or_authenticated(reach): """ Authenticated users who are not related to a document should be able to retrieve attachments related to a document with public or authenticated link reach. @@ -107,7 +107,7 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach original_url = f"http://localhost/media/{key:s}" response = client.get( - "/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url ) assert response.status_code == 200 @@ -135,7 +135,7 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach assert response.content.decode("utf-8") == "my prose" -def test_api_documents_retrieve_auth_authenticated_restricted(): +def test_api_documents_media_auth_authenticated_restricted(): """ Authenticated users who are not related to a document should not be allowed to retrieve attachments linked to a document that is restricted. @@ -150,7 +150,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted(): media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}" response = client.get( - "/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url ) assert response.status_code == 403 @@ -158,7 +158,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted(): @pytest.mark.parametrize("via", VIA) -def test_api_documents_retrieve_auth_related(via, mock_user_teams): +def test_api_documents_media_auth_related(via, mock_user_teams): """ Users who have a specific access to a document, whatever the role, should be able to retrieve related attachments. @@ -186,7 +186,7 @@ def test_api_documents_retrieve_auth_related(via, mock_user_teams): original_url = f"http://localhost/media/{key:s}" response = client.get( - "/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url ) assert response.status_code == 200 diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 3368395d..deb06038 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -31,6 +31,7 @@ def test_api_documents_retrieve_anonymous_public(): "favorite": False, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": document.link_role == "editor", "retrieve": True, "update": document.link_role == "editor", @@ -91,6 +92,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "destroy": False, "favorite": True, "invite_owner": False, + "media_auth": True, "link_configuration": False, "partial_update": document.link_role == "editor", "retrieve": True, diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index cc195911..91c17265 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -101,6 +101,7 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role) "destroy": False, "favorite": False, "invite_owner": False, + "media_auth": False, "link_configuration": False, "partial_update": False, "retrieve": False, @@ -137,6 +138,7 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach): "favorite": is_authenticated, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": False, "retrieve": True, "update": False, @@ -172,6 +174,7 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach): "favorite": is_authenticated, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": True, "retrieve": True, "update": True, @@ -196,6 +199,7 @@ def test_models_documents_get_abilities_owner(): "favorite": True, "invite_owner": True, "link_configuration": True, + "media_auth": True, "partial_update": True, "retrieve": True, "update": True, @@ -219,6 +223,7 @@ def test_models_documents_get_abilities_administrator(): "favorite": True, "invite_owner": False, "link_configuration": True, + "media_auth": True, "partial_update": True, "retrieve": True, "update": True, @@ -245,6 +250,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "favorite": True, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": True, "retrieve": True, "update": True, @@ -273,6 +279,7 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries): "favorite": True, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": False, "retrieve": True, "update": False, @@ -302,6 +309,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "favorite": True, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": False, "retrieve": True, "update": False, diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl index 96d29c93..1b21ebc6 100644 --- a/src/helm/env.d/dev/values.impress.yaml.gotmpl +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -7,8 +7,8 @@ backend: replicas: 1 envVars: DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io,http://impress.127.0.0.1.nip.io - DJANGO_CONFIGURATION: Production - DJANGO_ALLOWED_HOSTS: "*" + DJANGO_CONFIGURATION: Feature + DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io DJANGO_SECRET_KEY: {{ .Values.djangoSecretKey }} DJANGO_SETTINGS_MODULE: impress.settings DJANGO_SUPERUSER_PASSWORD: admin @@ -113,7 +113,7 @@ ingressMedia: host: impress.127.0.0.1.nip.io annotations: - nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/retrieve-auth/ + nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/media-auth/ nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256" nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000 nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1 diff --git a/src/helm/impress/values.yaml b/src/helm/impress/values.yaml index 0a27ed26..7a994d87 100644 --- a/src/helm/impress/values.yaml +++ b/src/helm/impress/values.yaml @@ -116,7 +116,7 @@ ingressMedia: additional: [] annotations: - nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/retrieve-auth/ + nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/media-auth/ nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256" nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000