diff --git a/api/edge_api/identities/edge_identity_service.py b/api/edge_api/identities/edge_identity_service.py new file mode 100644 index 000000000000..0c8d2633e4c0 --- /dev/null +++ b/api/edge_api/identities/edge_identity_service.py @@ -0,0 +1,15 @@ +import typing + +from environments.dynamodb.dynamodb_wrapper import DynamoEnvironmentV2Wrapper +from environments.dynamodb.types import IdentityOverrideV2 + +ddb_environment_v2_wrapper = DynamoEnvironmentV2Wrapper() + + +def get_edge_identity_overrides( + environment_id: int, feature_id: int +) -> typing.List[IdentityOverrideV2]: + override_items = ddb_environment_v2_wrapper.get_identity_overrides_by_feature_id( + environment_id=environment_id, feature_id=feature_id + ) + return [IdentityOverrideV2.parse_obj(item) for item in override_items] diff --git a/api/edge_api/identities/models.py b/api/edge_api/identities/models.py index 3cde399ad933..7e12395999f4 100644 --- a/api/edge_api/identities/models.py +++ b/api/edge_api/identities/models.py @@ -183,6 +183,7 @@ def save(self, user: FFAdminUser = None, master_api_key: MasterAPIKey = None): "environment_api_key": self.environment_api_key, "changes": changes, "identity_uuid": str(self.identity_uuid), + "identifier": self.identifier, } ) self._reset_initial_state() diff --git a/api/edge_api/identities/permissions.py b/api/edge_api/identities/permissions.py index 8184d7cc51db..f28d3368647e 100644 --- a/api/edge_api/identities/permissions.py +++ b/api/edge_api/identities/permissions.py @@ -1,9 +1,14 @@ from contextlib import suppress +from django.http import HttpRequest +from django.views import View from rest_framework.permissions import BasePermission from environments.models import Environment -from environments.permissions.constants import UPDATE_FEATURE_STATE +from environments.permissions.constants import ( + UPDATE_FEATURE_STATE, + VIEW_IDENTITIES, +) class EdgeIdentityWithIdentifierViewPermissions(BasePermission): @@ -15,3 +20,12 @@ def has_permission(self, request, view): UPDATE_FEATURE_STATE, environment ) return False + + +class GetEdgeIdentityOverridesPermission(BasePermission): + def has_permission(self, request: HttpRequest, view: View) -> bool: + environment_pk = view.kwargs.get("environment_pk") + with suppress(Environment.DoesNotExist): + environment = Environment.objects.get(pk=environment_pk) + return request.user.has_environment_permission(VIEW_IDENTITIES, environment) + return False diff --git a/api/edge_api/identities/serializers.py b/api/edge_api/identities/serializers.py index 40039a2bd080..6b1232609a06 100644 --- a/api/edge_api/identities/serializers.py +++ b/api/edge_api/identities/serializers.py @@ -19,6 +19,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError +from environments.dynamodb.types import IdentityOverrideV2 from environments.models import Environment from features.models import Feature, FeatureState, FeatureStateValue from features.multivariate.models import MultivariateFeatureOption @@ -77,7 +78,12 @@ def to_internal_value(self, data): class FeatureStateValueEdgeIdentityField(serializers.Field): def to_representation(self, obj): - identity_id = self.parent.get_identity_uuid() + identity: EdgeIdentity = self.parent.context["identity"] + environment: Environment = self.parent.context["environment"] + identity_id = identity.get_hash_key( + environment.use_identity_composite_key_for_hashing + ) + return obj.get_value(identity_id=identity_id) def get_attribute(self, instance): @@ -124,7 +130,7 @@ class Meta: swagger_schema_fields = {"type": "integer/string"} -class EdgeIdentityFeatureStateSerializer(serializers.Serializer): +class BaseEdgeIdentityFeatureStateSerializer(serializers.Serializer): feature_state_value = FeatureStateValueEdgeIdentityField( allow_null=True, required=False, default=None ) @@ -133,13 +139,8 @@ class EdgeIdentityFeatureStateSerializer(serializers.Serializer): many=True, required=False ) enabled = serializers.BooleanField(required=False, default=False) - identity_uuid = serializers.SerializerMethodField() - featurestate_uuid = serializers.CharField(required=False, read_only=True) - def get_identity_uuid(self, obj=None): - return self.context["view"].identity.identity_uuid - def save(self, **kwargs): view = self.context["view"] request = self.context["request"] @@ -200,6 +201,13 @@ def save(self, **kwargs): return self.instance +class EdgeIdentityFeatureStateSerializer(BaseEdgeIdentityFeatureStateSerializer): + identity_uuid = serializers.SerializerMethodField() + + def get_identity_uuid(self, obj=None): + return self.context["view"].identity.identity_uuid + + class EdgeIdentityIdentifierSerializer(serializers.Serializer): identifier = serializers.CharField(required=True, max_length=2000) @@ -227,3 +235,32 @@ class EdgeIdentityFsQueryparamSerializer(serializers.Serializer): feature = serializers.IntegerField( required=False, help_text="ID of the feature to filter by" ) + + +class GetEdgeIdentityOverridesQuerySerializer(serializers.Serializer): + feature = serializers.IntegerField(required=False) + + +class GetEdgeIdentityOverridesResultSerializer(serializers.Serializer): + identifier = serializers.CharField() + identity_uuid = serializers.CharField() + feature_state = BaseEdgeIdentityFeatureStateSerializer() + + def to_representation(self, instance: IdentityOverrideV2): + # Since the FeatureStateValueEdgeIdentityField relies on having this data + # available to generate the value of the feature state, we need to set this + # and make it available to the field class. to_representation seems like the + # best place for this since we only care about serialization here (not + # deserialization). + self.context["identity"] = EdgeIdentity.from_identity_document( + { + "identifier": instance.identifier, + "identity_uuid": instance.identity_uuid, + "environment_api_key": self.context["environment"].api_key, + } + ) + return super().to_representation(instance) + + +class GetEdgeIdentityOverridesSerializer(serializers.Serializer): + results = GetEdgeIdentityOverridesResultSerializer(many=True) diff --git a/api/edge_api/identities/tasks.py b/api/edge_api/identities/tasks.py index 22f98f0738e0..5a13936a973a 100644 --- a/api/edge_api/identities/tasks.py +++ b/api/edge_api/identities/tasks.py @@ -139,6 +139,7 @@ def generate_audit_log_records( def update_flagsmith_environments_v2_identity_overrides( environment_api_key: str, identity_uuid: str, + identifier: str, changes: IdentityChangeset, ) -> None: feature_override_changes = changes["feature_overrides"] @@ -153,5 +154,6 @@ def update_flagsmith_environments_v2_identity_overrides( identity_uuid=identity_uuid, environment_api_key=environment_api_key, environment_id=environment.id, + identifier=identifier, ) dynamodb_wrapper_v2.update_identity_overrides(identity_override_changeset) diff --git a/api/edge_api/identities/views.py b/api/edge_api/identities/views.py index 9af82a7ea762..562f3eb95660 100644 --- a/api/edge_api/identities/views.py +++ b/api/edge_api/identities/views.py @@ -11,7 +11,7 @@ from flag_engine.identities.traits.models import TraitModel from pyngo import drf_error_details from rest_framework import status, viewsets -from rest_framework.decorators import action +from rest_framework.decorators import action, api_view, permission_classes from rest_framework.exceptions import ( NotFound, PermissionDenied, @@ -24,6 +24,7 @@ RetrieveModelMixin, ) from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import GenericViewSet @@ -40,6 +41,8 @@ EdgeIdentityTraitsSerializer, EdgeIdentityWithIdentifierFeatureStateDeleteRequestBody, EdgeIdentityWithIdentifierFeatureStateRequestBody, + GetEdgeIdentityOverridesQuerySerializer, + GetEdgeIdentityOverridesSerializer, ) from environments.identities.serializers import ( IdentityAllFeatureStatesSerializer, @@ -55,9 +58,13 @@ from projects.exceptions import DynamoNotEnabledError from util.mappers import map_engine_identity_to_identity_document +from . import edge_identity_service from .exceptions import TraitPersistenceError from .models import EdgeIdentity -from .permissions import EdgeIdentityWithIdentifierViewPermissions +from .permissions import ( + EdgeIdentityWithIdentifierViewPermissions, + GetEdgeIdentityOverridesPermission, +) @method_decorator( @@ -238,6 +245,15 @@ def get_object(self): return feature_state + def get_serializer_context(self) -> dict: + return { + **super().get_serializer_context(), + "identity": self.identity, + "environment": Environment.objects.get( + api_key=self.kwargs["environment_api_key"] + ), + } + @swagger_auto_schema(query_serializer=EdgeIdentityFsQueryparamSerializer()) def list(self, request, *args, **kwargs): q_params_serializer = EdgeIdentityFsQueryparamSerializer( @@ -314,7 +330,14 @@ def put(self, request, *args, **kwargs): serializer = EdgeIdentityFeatureStateSerializer( instance=feature_state, data=request.data, - context={"view": self, "request": request}, + context={ + "view": self, + "request": request, + "identity": self.identity, + "environment": Environment.objects.get( + api_key=self.kwargs["environment_api_key"] + ), + }, ) serializer.is_valid(raise_exception=True) serializer.save() @@ -334,3 +357,28 @@ def delete(self, request, *args, **kwargs): master_api_key=getattr(request, "master_api_key", None), ) return Response(status=status.HTTP_204_NO_CONTENT) + + +@swagger_auto_schema( + method="GET", + query_serializer=GetEdgeIdentityOverridesQuerySerializer(), + responses={200: GetEdgeIdentityOverridesSerializer()}, +) +@api_view(http_method_names=["GET"]) +@permission_classes([IsAuthenticated, GetEdgeIdentityOverridesPermission]) +def get_edge_identity_overrides( + request: Request, environment_pk: int, **kwargs +) -> Response: + query_serializer = GetEdgeIdentityOverridesQuerySerializer( + data=request.query_params + ) + query_serializer.is_valid(raise_exception=True) + feature_id = query_serializer.validated_data.get("feature") + environment = Environment.objects.get(pk=environment_pk) + items = edge_identity_service.get_edge_identity_overrides( + environment_pk, feature_id=feature_id + ) + response_serializer = GetEdgeIdentityOverridesSerializer( + instance={"results": items}, context={"environment": environment} + ) + return Response(response_serializer.data) diff --git a/api/environments/dynamodb/types.py b/api/environments/dynamodb/types.py index d43d124e88e1..88b6c192a6aa 100644 --- a/api/environments/dynamodb/types.py +++ b/api/environments/dynamodb/types.py @@ -77,6 +77,8 @@ class IdentityOverrideV2(BaseModel): environment_id: str document_key: str environment_api_key: str + identifier: str + identity_uuid: str feature_state: FeatureStateModel diff --git a/api/environments/urls.py b/api/environments/urls.py index 2978868f5782..ceb05e97dff0 100644 --- a/api/environments/urls.py +++ b/api/environments/urls.py @@ -6,6 +6,7 @@ EdgeIdentityFeatureStateViewSet, EdgeIdentityViewSet, EdgeIdentityWithIdentifierFeatureStateView, + get_edge_identity_overrides, ) from features.views import ( EnvironmentFeatureStateViewSet, @@ -142,4 +143,9 @@ create_segment_override, name="create-segment-override", ), + path( + "/edge-identity-overrides", + get_edge_identity_overrides, + name="edge-identity-overrides", + ), ] diff --git a/api/poetry.lock b/api/poetry.lock index 2500e31dd8d9..fb3055b781cd 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiohttp" @@ -2094,16 +2094,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -3437,7 +3427,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -3445,15 +3434,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -3470,7 +3452,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -3478,7 +3459,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, diff --git a/api/tests/unit/edge_api/identities/conftest.py b/api/tests/unit/edge_api/identities/conftest.py index 30719582e096..d1fc3b3b0cb6 100644 --- a/api/tests/unit/edge_api/identities/conftest.py +++ b/api/tests/unit/edge_api/identities/conftest.py @@ -1,6 +1,8 @@ import pytest from edge_api.identities.models import EdgeIdentity +from environments.models import Environment +from features.models import Feature @pytest.fixture() @@ -45,3 +47,70 @@ def identity_document_without_fs(environment): @pytest.fixture() def edge_identity_model(identity_document_without_fs): return EdgeIdentity.from_identity_document(identity_document_without_fs) + + +@pytest.fixture() +def edge_identity_override_document( + environment: Environment, + feature: Feature, + edge_identity_model: EdgeIdentity, +) -> dict: + return { + "environment_id": environment.id, + "document_key": f"identity_override:{feature.id}:{edge_identity_model.identity_uuid}", + "environment_api_key": environment.api_key, + "identifier": edge_identity_model.identifier, + "identity_uuid": edge_identity_model.identity_uuid, + "feature_state": { + "django_id": None, + "enabled": True, + "feature": {"id": feature.id, "name": feature.name, "type": feature.type}, + "featurestate_uuid": "a7495917-ee57-41d1-a64e-e0697dbc57fb", + "feature_segment": None, + "feature_state_value": None, + "multivariate_feature_state_values": [], + }, + } + + +@pytest.fixture() +def identity_document_2(environment): + return { + "composite_key": f"{environment.api_key}_user_2_test", + "identity_traits": [], + "identity_features": [], + "identifier": "user_2_test", + "created_date": "2021-09-21T10:12:42.230257+00:00", + "environment_api_key": environment.api_key, + "identity_uuid": "c0ed9184-2832-42dc-a132-5eb45afd1181", + "django_id": None, + } + + +@pytest.fixture() +def edge_identity_model_2(identity_document_2): + return EdgeIdentity.from_identity_document(identity_document_2) + + +@pytest.fixture() +def edge_identity_override_document_2( + environment: Environment, + feature: Feature, + edge_identity_model_2: EdgeIdentity, +) -> dict: + return { + "environment_id": environment.id, + "document_key": f"identity_override:{feature.id}:{edge_identity_model_2.identity_uuid}", + "environment_api_key": environment.api_key, + "identifier": edge_identity_model_2.identifier, + "identity_uuid": edge_identity_model_2.identity_uuid, + "feature_state": { + "django_id": None, + "enabled": True, + "feature": {"id": feature.id, "name": feature.name, "type": feature.type}, + "featurestate_uuid": "a7495917-ee57-41d1-a64e-e0697dbc57fb", + "feature_segment": None, + "feature_state_value": None, + "multivariate_feature_state_values": [], + }, + } diff --git a/api/tests/unit/edge_api/identities/test_edge_api_identities_views.py b/api/tests/unit/edge_api/identities/test_edge_api_identities_views.py index 3b94d2f63811..a6d50ec45bea 100644 --- a/api/tests/unit/edge_api/identities/test_edge_api_identities_views.py +++ b/api/tests/unit/edge_api/identities/test_edge_api_identities_views.py @@ -1,13 +1,21 @@ +from typing import Callable + from django.urls import reverse +from pytest_mock import MockerFixture from rest_framework import status from rest_framework.permissions import IsAuthenticated +from rest_framework.test import APIClient +from edge_api.identities.models import EdgeIdentity from edge_api.identities.views import EdgeIdentityViewSet +from environments.models import Environment from environments.permissions.constants import ( MANAGE_IDENTITIES, + VIEW_ENVIRONMENT, VIEW_IDENTITIES, ) from environments.permissions.permissions import NestedEnvironmentPermissions +from features.models import Feature def test_edge_identity_view_set_get_permissions(): @@ -85,3 +93,92 @@ def test_edge_identity_viewset_returns_404_for_invalid_environment_key(admin_cli # Then assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_get_edge_identity_overrides_for_a_feature( + staff_client: APIClient, + with_environment_permissions: Callable, + mocker: MockerFixture, + feature: Feature, + environment: Environment, + edge_identity_override_document: dict, + edge_identity_override_document_2: dict, + edge_identity_model: EdgeIdentity, + edge_identity_model_2: EdgeIdentity, +) -> None: + # Given + base_url = reverse( + "api-v1:environments:edge-identity-overrides", args=[environment.pk] + ) + url = f"{base_url}?feature={feature.id}" + with_environment_permissions([VIEW_IDENTITIES]) + + mock_dynamodb_wrapper = mocker.MagicMock() + mocker.patch( + "edge_api.identities.edge_identity_service.ddb_environment_v2_wrapper", + mock_dynamodb_wrapper, + ) + + mock_dynamodb_wrapper.get_identity_overrides_by_feature_id.return_value = [ + edge_identity_override_document, + edge_identity_override_document_2, + ] + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert len(response_json["results"]) == 2 + assert response_json["results"][0] == { + "identity_uuid": edge_identity_model.identity_uuid, + "identifier": edge_identity_model.identifier, + "feature_state": { + "feature_state_value": None, + "multivariate_feature_state_values": [], + "featurestate_uuid": edge_identity_override_document["feature_state"][ + "featurestate_uuid" + ], + "enabled": True, + "feature": feature.id, + }, + } + assert response_json["results"][1] == { + "identity_uuid": edge_identity_model_2.identity_uuid, + "identifier": edge_identity_model_2.identifier, + "feature_state": { + "feature_state_value": None, + "multivariate_feature_state_values": [], + "featurestate_uuid": edge_identity_override_document_2["feature_state"][ + "featurestate_uuid" + ], + "enabled": True, + "feature": feature.id, + }, + } + + mock_dynamodb_wrapper.get_identity_overrides_by_feature_id.assert_called_once_with( + environment_id=environment.id, feature_id=feature.id + ) + + +def test_user_without_manage_identities_permission_cannot_get_edge_identity_overrides_for_a_feature( + staff_client: APIClient, + with_environment_permissions: Callable, + feature: Feature, + environment: Environment, +) -> None: + # Given + base_url = reverse( + "api-v1:environments:edge-identity-overrides", args=[environment.pk] + ) + url = f"{base_url}?feature={feature.id}" + with_environment_permissions([VIEW_ENVIRONMENT]) + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/api/tests/unit/edge_api/identities/test_edge_identity_models.py b/api/tests/unit/edge_api/identities/test_edge_identity_models.py index 97ed26cffe57..a9cbec39f94f 100644 --- a/api/tests/unit/edge_api/identities/test_edge_identity_models.py +++ b/api/tests/unit/edge_api/identities/test_edge_identity_models.py @@ -320,6 +320,7 @@ def test_edge_identity_save_called__feature_override_added__expected_tasks_calle "environment_api_key": edge_identity_model.environment_api_key, "changes": expected_changes, "identity_uuid": expected_identity_uuid, + "identifier": edge_identity_model.identifier, } ) @@ -386,6 +387,7 @@ def test_edge_identity_save_called__feature_override_removed__expected_tasks_cal "environment_api_key": edge_identity_model.environment_api_key, "changes": expected_changes, "identity_uuid": expected_identity_uuid, + "identifier": edge_identity_model.identifier, } ) @@ -474,5 +476,6 @@ def test_edge_identity_save_called_generate_audit_records_if_feature_override_up "environment_api_key": edge_identity_model.environment_api_key, "changes": expected_changes, "identity_uuid": expected_identity_uuid, + "identifier": edge_identity_model.identifier, } ) diff --git a/api/tests/unit/edge_api/identities/test_unit_edge_api_identities_tasks.py b/api/tests/unit/edge_api/identities/test_unit_edge_api_identities_tasks.py index 91958089eea9..0b111f991794 100644 --- a/api/tests/unit/edge_api/identities/test_unit_edge_api_identities_tasks.py +++ b/api/tests/unit/edge_api/identities/test_unit_edge_api_identities_tasks.py @@ -357,6 +357,7 @@ def test_update_flagsmith_environments_v2_identity_overrides__call_expected( ) dynamodb_wrapper_v2_mock = dynamodb_wrapper_v2_cls_mock.return_value identity_uuid = "a35a02f2-fefd-4932-8f5c-e84a0bf542c7" + identifier = "identity1" changes = { "feature_overrides": { "test_feature": { @@ -401,6 +402,8 @@ def test_update_flagsmith_environments_v2_identity_overrides__call_expected( "document_key": f"identity_override:3:{identity_uuid}", "environment_id": str(environment.id), "environment_api_key": environment.api_key, + "identifier": identifier, + "identity_uuid": identity_uuid, "feature_state": { "enabled": True, "feature_state_value": "deleted", @@ -420,6 +423,8 @@ def test_update_flagsmith_environments_v2_identity_overrides__call_expected( "document_key": f"identity_override:1:{identity_uuid}", "environment_id": str(environment.id), "environment_api_key": environment.api_key, + "identifier": identifier, + "identity_uuid": identity_uuid, "feature_state": { "enabled": True, "feature_state_value": "updated", @@ -437,6 +442,8 @@ def test_update_flagsmith_environments_v2_identity_overrides__call_expected( "document_key": f"identity_override:2:{identity_uuid}", "environment_id": str(environment.id), "environment_api_key": environment.api_key, + "identifier": identifier, + "identity_uuid": identity_uuid, "feature_state": { "enabled": True, "feature_state_value": "new", @@ -457,6 +464,7 @@ def test_update_flagsmith_environments_v2_identity_overrides__call_expected( environment_api_key=environment.api_key, identity_uuid=identity_uuid, changes=changes, + identifier=identifier, ) # Then @@ -475,6 +483,7 @@ def test_update_flagsmith_environments_v2_identity_overrides__no_overrides__call ) dynamodb_wrapper_v2_mock = dynamodb_wrapper_v2_cls_mock.return_value identity_uuid = "a35a02f2-fefd-4932-8f5c-e84a0bf542c7" + identifier = "identity1" changes = {"feature_overrides": []} # When @@ -482,6 +491,7 @@ def test_update_flagsmith_environments_v2_identity_overrides__no_overrides__call environment_api_key=environment.api_key, identity_uuid=identity_uuid, changes=changes, + identifier=identifier, ) # Then diff --git a/api/tests/unit/environments/dynamodb/test_unit_dynamodb_environment_v2_wrapper.py b/api/tests/unit/environments/dynamodb/test_unit_dynamodb_environment_v2_wrapper.py index 01825897ebfa..6e08775a1a32 100644 --- a/api/tests/unit/environments/dynamodb/test_unit_dynamodb_environment_v2_wrapper.py +++ b/api/tests/unit/environments/dynamodb/test_unit_dynamodb_environment_v2_wrapper.py @@ -65,12 +65,15 @@ def test_environment_v2_wrapper__update_identity_overrides__put_expected( wrapper = DynamoEnvironmentV2Wrapper() identity_uuid = str(uuid.uuid4()) + identifier = "identity1" override_document = IdentityOverrideV2.parse_obj( { "environment_id": str(environment.id), "document_key": f"identity_override:{feature.id}:{identity_uuid}", "environment_api_key": environment.api_key, "feature_state": map_feature_state_to_engine(feature_state), + "identifier": identifier, + "identity_uuid": identity_uuid, } ) @@ -102,6 +105,7 @@ def test_environment_v2_wrapper__update_identity_overrides__delete_expected( wrapper = DynamoEnvironmentV2Wrapper() identity_uuid = str(uuid.uuid4()) + identifier = "identity1" override_document_data = map_identity_override_to_identity_override_document( IdentityOverrideV2.parse_obj( { @@ -109,6 +113,8 @@ def test_environment_v2_wrapper__update_identity_overrides__delete_expected( "document_key": f"identity_override:{feature.id}:{identity_uuid}", "environment_api_key": environment.api_key, "feature_state": map_feature_state_to_engine(feature_state), + "identifier": identifier, + "identity_uuid": identity_uuid, } ) ) diff --git a/api/util/mappers/dynamodb.py b/api/util/mappers/dynamodb.py index 2b39e0525e20..5cae5dc0afae 100644 --- a/api/util/mappers/dynamodb.py +++ b/api/util/mappers/dynamodb.py @@ -87,9 +87,10 @@ def map_engine_feature_state_to_identity_override( *, feature_state: "FeatureStateModel", identity_uuid: str, + identifier: str, environment_api_key: str, environment_id: int, -) -> list[IdentityOverrideV2]: +) -> IdentityOverrideV2: return IdentityOverrideV2( document_key=get_environments_v2_identity_override_document_key( feature_id=feature_state.feature.id, @@ -98,6 +99,8 @@ def map_engine_feature_state_to_identity_override( environment_id=str(environment_id), environment_api_key=environment_api_key, feature_state=feature_state, + identifier=identifier, + identity_uuid=identity_uuid, ) @@ -105,6 +108,7 @@ def map_identity_changeset_to_identity_override_changeset( *, identity_changeset: "IdentityChangeset", identity_uuid: str, + identifier: str, environment_api_key: str, environment_id: int, ) -> "IdentityOverridesV2Changeset": @@ -119,6 +123,7 @@ def map_identity_changeset_to_identity_override_changeset( map_engine_feature_state_to_identity_override( feature_state=feature_state, identity_uuid=identity_uuid, + identifier=identifier, environment_api_key=environment_api_key, environment_id=environment_id, ) @@ -129,6 +134,7 @@ def map_identity_changeset_to_identity_override_changeset( map_engine_feature_state_to_identity_override( feature_state=feature_state, identity_uuid=identity_uuid, + identifier=identifier, environment_api_key=environment_api_key, environment_id=environment_id, )