Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/add master keys to enviroment feature state viewset #1225

Merged
merged 9 commits into from
Jun 29, 2022
8 changes: 7 additions & 1 deletion api/environments/permissions/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from rest_framework.permissions import BasePermission

from environments.models import Environment
from environments.permissions.constants import VIEW_ENVIRONMENT
from projects.models import Project


Expand Down Expand Up @@ -101,7 +102,12 @@ def has_permission(self, request, view):
elif view.action == "create":
return request.user.is_environment_admin(environment)

return view.action == "list" or view.detail
elif view.action == "list":
return request.user.has_environment_permission(
VIEW_ENVIRONMENT, environment
)

return view.detail

def has_object_permission(self, request, view, obj):
if view.action in self.action_permission_map:
Expand Down
8 changes: 6 additions & 2 deletions api/features/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,12 +562,16 @@ def get_environment_flags_list(
return list(feature_states_dict.values())

@classmethod
def get_environment_flags_queryset(cls, environment_id: int) -> QuerySet:
def get_environment_flags_queryset(
cls, environment_id: int, feature_name: str = None
) -> QuerySet:
"""
Get a queryset of the latest live versions of an environments' feature states
"""

feature_states_list = cls.get_environment_flags_list(environment_id)
feature_states_list = cls.get_environment_flags_list(
environment_id, feature_name
)
return FeatureState.objects.filter(id__in=[fs.id for fs in feature_states_list])

@classmethod
Expand Down
52 changes: 40 additions & 12 deletions api/features/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,25 +113,53 @@ def has_object_permission(
return False


class EnvironmentFeatureStatePermissions(BasePermission):
def has_permission(self, request, view):
if view.action == "create":
environment_api_key = view.kwargs.get("environment_api_key")
if not environment_api_key:
return False
class MasterAPIKeyEnvironmentFeatureStatePermissions(BasePermission):
def has_permission(self, request: HttpRequest, view: str) -> bool:
master_api_key = getattr(request, "master_api_key", None)
if not master_api_key:
return False
environment_api_key = view.kwargs.get("environment_api_key")
if not environment_api_key:
return False

with suppress(Environment.DoesNotExist):
environment = Environment.objects.get(api_key=environment_api_key)
return request.user.has_environment_permission(
permission=UPDATE_FEATURE_STATE, environment=environment
)
return environment.project.organisation == master_api_key.organisation
return False

def has_object_permission(
self, request: HttpRequest, view: str, obj: FeatureState
) -> bool:
master_api_key = getattr(request, "master_api_key", None)
if master_api_key:
return obj.environment.project.organisation == master_api_key.organisation
return False

if view.action == "list":

class EnvironmentFeatureStatePermissions(IsAuthenticated):
def has_permission(self, request, view):
action_permission_map = {
"list": VIEW_ENVIRONMENT,
"create": UPDATE_FEATURE_STATE,
}
if not super().has_permission(request, view):
return False

# detail view means we can just defer to object permissions
if view.detail:
return True

# move on to object specific permissions
return view.detail
environment_api_key = view.kwargs.get("environment_api_key")
with suppress(Environment.DoesNotExist):
environment = Environment.objects.get(api_key=environment_api_key)
return request.user.has_environment_permission(
action_permission_map.get(view.action), environment
)
return False

def has_object_permission(self, request, view, obj):
if request.user.is_anonymous:
return False
return request.user.has_environment_permission(
permission=UPDATE_FEATURE_STATE, environment=obj.environment
)
Expand Down
126 changes: 0 additions & 126 deletions api/features/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import pytz
from django.forms import model_to_dict
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APIClient, APITestCase

Expand Down Expand Up @@ -870,131 +869,6 @@ def test_create_feature_only_triggers_write_to_dynamodb_once_per_environment(
mock_dynamo_environment_wrapper.write_environments.assert_called_once()


@pytest.mark.django_db()
class FeatureStateViewSetTestCase(TestCase):
def setUp(self) -> None:
self.organisation = Organisation.objects.create(name="Test org")
self.project = Project.objects.create(
name="Test project", organisation=self.organisation
)
self.environment = Environment.objects.create(
project=self.project, name="Test environment"
)
self.feature = Feature.objects.create(
name="test-feature", project=self.project, type="CONFIG", initial_value=12
)
self.user = FFAdminUser.objects.create(email="test@example.com")
self.user.add_organisation(self.organisation, OrganisationRole.ADMIN)
self.client = APIClient()
self.client.force_authenticate(self.user)

def test_update_feature_state_value_updates_feature_state_value(self):
# Given
feature_state = FeatureState.objects.get(
environment=self.environment, feature=self.feature
)
url = reverse(
"api-v1:environments:environment-featurestates-detail",
args=[self.environment.api_key, feature_state.id],
)
new_value = "new-value"
data = {
"id": feature_state.id,
"feature_state_value": new_value,
"enabled": False,
"feature": self.feature.id,
"environment": self.environment.id,
"identity": None,
"feature_segment": None,
}

# When
self.client.put(url, data=json.dumps(data), content_type="application/json")

# Then
feature_state.refresh_from_db()
assert feature_state.get_feature_state_value() == new_value

def test_can_filter_feature_states_to_show_identity_overrides_only(self):
# Given
FeatureState.objects.get(environment=self.environment, feature=self.feature)

identifier = "test-identity"
identity = Identity.objects.create(
identifier=identifier, environment=self.environment
)
FeatureState.objects.create(
environment=self.environment, feature=self.feature, identity=identity
)

base_url = reverse(
"api-v1:environments:environment-featurestates-list",
args=[self.environment.api_key],
)
url = base_url + "?anyIdentity&feature=" + str(self.feature.id)

# When
res = self.client.get(url)

# Then
assert res.status_code == status.HTTP_200_OK

# and
assert len(res.json().get("results")) == 1

# and
assert res.json()["results"][0]["identity"]["identifier"] == identifier

def test_get_feature_states_only_returns_latest_versions(self):
# Given
feature_state = FeatureState.objects.get(
environment=self.environment, feature=self.feature
)
feature_state_v2 = feature_state.clone(
env=self.environment, live_from=timezone.now(), version=2
)

url = reverse(
"api-v1:environments:environment-featurestates-list",
args=[self.environment.api_key],
)

# When
response = self.client.get(url)

# Then
assert response.status_code == status.HTTP_200_OK

response_json = response.json()
assert len(response_json["results"]) == 1
assert response_json["results"][0]["id"] == feature_state_v2.id

def test_get_feature_states_does_not_return_null_versions(self):
# Given
feature_state = FeatureState.objects.get(
environment=self.environment, feature=self.feature
)

FeatureState.objects.create(
environment=self.environment, feature=self.feature, version=None
)

url = reverse(
"api-v1:environments:environment-featurestates-list",
args=[self.environment.api_key],
)

# When
response = self.client.get(url)

# Then
assert response.status_code == status.HTTP_200_OK

response_json = response.json()
assert len(response_json["results"]) == 1
assert response_json["results"][0]["id"] == feature_state.id


@pytest.mark.django_db
class SDKFeatureStatesTestCase(APITestCase):
def setUp(self) -> None:
Expand Down
28 changes: 15 additions & 13 deletions api/features/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@
from drf_yasg2.utils import swagger_auto_schema
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import (
NotFound,
PermissionDenied,
ValidationError,
)
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.generics import GenericAPIView, get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
Expand All @@ -35,7 +31,6 @@
from environments.authentication import EnvironmentKeyAuthentication
from environments.identities.models import Identity
from environments.models import Environment
from environments.permissions.constants import VIEW_ENVIRONMENT
from environments.permissions.permissions import (
EnvironmentKeyPermissions,
NestedEnvironmentPermissions,
Expand All @@ -49,6 +44,7 @@
FeaturePermissions,
FeatureStatePermissions,
IdentityFeatureStatePermissions,
MasterAPIKeyEnvironmentFeatureStatePermissions,
MasterAPIKeyFeatureStatePermissions,
)
from .serializers import (
Expand Down Expand Up @@ -267,6 +263,13 @@ def _filter_queryset(self, queryset: QuerySet) -> QuerySet:
required=False,
type=openapi.TYPE_INTEGER,
),
openapi.Parameter(
"feature_name",
openapi.IN_QUERY,
"Name of the feature to filter by.",
required=False,
type=openapi.TYPE_STRING,
),
openapi.Parameter(
"anyIdentity",
openapi.IN_QUERY,
Expand Down Expand Up @@ -303,13 +306,9 @@ def get_queryset(self):

try:
environment = Environment.objects.get(api_key=environment_api_key)
if not self.request.user.has_environment_permission(
VIEW_ENVIRONMENT, environment
):
raise PermissionDenied()

queryset = FeatureState.get_environment_flags_queryset(
environment_id=environment.id
environment_id=environment.id,
feature_name=self.request.query_params.get("feature_name"),
)
queryset = self._apply_query_param_filters(queryset)

Expand Down Expand Up @@ -484,7 +483,10 @@ def update_feature_state_value(self, value, feature_state):


class EnvironmentFeatureStateViewSet(BaseFeatureStateViewSet):
permission_classes = [IsAuthenticated, EnvironmentFeatureStatePermissions]
permission_classes = [
EnvironmentFeatureStatePermissions
| MasterAPIKeyEnvironmentFeatureStatePermissions
]

def get_queryset(self):
queryset = super().get_queryset().filter(feature_segment=None)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import json

import pytest
from django.urls import reverse
from pytest_lazyfixture import lazy_fixture
from rest_framework import status


@pytest.mark.parametrize(
"client", [(lazy_fixture("master_api_key_client")), (lazy_fixture("admin_client"))]
)
def test_update_feature_state_value_updates_feature_state_value(
client, environment, environment_api_key, feature, feature_state
):
# Given
url = reverse(
"api-v1:environments:environment-featurestates-detail",
args=[environment_api_key, feature_state],
)
new_value = "new-value"
data = {
"id": feature_state,
"feature_state_value": new_value,
"enabled": False,
"feature": feature,
"environment": environment,
"identity": None,
"feature_segment": None,
}

# When
response = client.put(url, data=json.dumps(data), content_type="application/json")

# Then
assert response.status_code == status.HTTP_200_OK
response.json()["feature_state_value"] == new_value
10 changes: 10 additions & 0 deletions api/tests/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from rest_framework.test import APIClient

from api_keys.models import MasterAPIKey
from environments.models import Environment
Expand Down Expand Up @@ -101,3 +102,12 @@ def organisation_one_project_one_feature_one(organisation_one_project_one):
def master_api_key(organisation):
_, key = MasterAPIKey.objects.create_key(name="test_key", organisation=organisation)
return key


@pytest.fixture()
def master_api_key_client(master_api_key):
# Can not use `api_client` fixture here because:
# https://docs.pytest.org/en/6.2.x/fixture.html#fixtures-can-be-requested-more-than-once-per-test-return-values-are-cached
api_client = APIClient()
api_client.credentials(HTTP_AUTHORIZATION="Api-Key " + master_api_key)
return api_client
18 changes: 18 additions & 0 deletions api/tests/unit/features/test_unit_features_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ def test_project_hide_disabled_flags_have_no_effect_on_feature_state_get_environ
assert feature_states.count() == 2


def test_feature_states_get_environment_flags_queryset_filter_using_feature_name(
environment, project
):
# Given
flag_1_name = "flag_1"
Feature.objects.create(default_enabled=True, name=flag_1_name, project=project)
Feature.objects.create(default_enabled=True, name="flag_2", project=project)

# When
feature_states = FeatureState.get_environment_flags_queryset(
environment_id=environment.id, feature_name=flag_1_name
)

# Then
assert feature_states.count() == 1
assert feature_states.first().feature.name == "flag_1"


@pytest.mark.parametrize(
"feature_state_version_generator",
(
Expand Down
Loading