diff --git a/.github/workflows/update-flagsmith-environment.yml b/.github/workflows/update-flagsmith-environment.yml new file mode 100644 index 000000000000..9510c817e02b --- /dev/null +++ b/.github/workflows/update-flagsmith-environment.yml @@ -0,0 +1,41 @@ +name: Update Flagsmith Defaults + +on: + schedule: + - cron: 0 8 * * * + +defaults: + run: + working-directory: api + +jobs: + update_server_defaults: + runs-on: ubuntu-latest + name: Update API Flagsmith Defaults + env: + FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL: https://edge.api.flagsmith.com/api/v1 + FLAGSMITH_ON_FLAGSMITH_SERVER_KEY: ${{ secrets.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY }} + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + cache: pip + + - name: Install Dependencies + run: make install + + - name: Update defaults + run: python manage.py updateflagsmithenvironment + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v5 + with: + commit-message: Update API Flagsmith Defaults + branch: chore/update-api-flagsmith-environment + delete-branch: true + title: 'chore: update Flagsmith environment document' + labels: api diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 664a61192127..846ef30e824a 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -148,6 +148,7 @@ "integrations.slack", "integrations.webhook", "integrations.dynatrace", + "integrations.flagsmith", # Rate limiting admin endpoints "axes", "telemetry", @@ -919,3 +920,13 @@ # Limit the count of password reset emails that can be dispatched within the `PASSWORD_RESET_EMAIL_COOLDOWN` timeframe. MAX_PASSWORD_RESET_EMAILS = env.int("MAX_PASSWORD_RESET_EMAILS", 5) + +FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE = env.bool( + "FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE", default=True +) +FLAGSMITH_ON_FLAGSMITH_SERVER_KEY = env( + "FLAGSMITH_ON_FLAGSMITH_SERVER_KEY", default=None +) +FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL = env( + "FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL", default=FLAGSMITH_ON_FLAGSMITH_API_URL +) diff --git a/api/integrations/flagsmith/__init__.py b/api/integrations/flagsmith/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/flagsmith/client.py b/api/integrations/flagsmith/client.py new file mode 100644 index 000000000000..3a2ab202ae4b --- /dev/null +++ b/api/integrations/flagsmith/client.py @@ -0,0 +1,54 @@ +""" +Wrapper module for the flagsmith client to implement singleton behaviour and provide some +additional logic by wrapping the client. + +Usage: + +``` +environment_flags = get_client().get_environment_flags() +identity_flags = get_client().get_identity_flags() +``` + +Possible extensions: + - Allow for multiple clients? +""" +import typing + +from django.conf import settings +from flagsmith import Flagsmith +from flagsmith.offline_handlers import LocalFileHandler + +from integrations.flagsmith.exceptions import FlagsmithIntegrationError +from integrations.flagsmith.flagsmith_service import ENVIRONMENT_JSON_PATH + +_flagsmith_client: typing.Optional[Flagsmith] = None + + +def get_client() -> Flagsmith: + global _flagsmith_client + + if not _flagsmith_client: + _flagsmith_client = Flagsmith(**_get_client_kwargs()) + + return _flagsmith_client + + +def _get_client_kwargs() -> dict[str, typing.Any]: + _default_kwargs = {"offline_handler": LocalFileHandler(ENVIRONMENT_JSON_PATH)} + + if settings.FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE: + return {"offline_mode": True, **_default_kwargs} + elif ( + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY + and settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL + ): + return { + "environment_key": settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY, + "api_url": settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL, + **_default_kwargs, + } + + raise FlagsmithIntegrationError( + "Must either use offline mode, or provide " + "FLAGSMITH_ON_FLAGSMITH_SERVER_KEY and FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL." + ) diff --git a/api/integrations/flagsmith/data/environment.json b/api/integrations/flagsmith/data/environment.json new file mode 100644 index 000000000000..b058b30c0d50 --- /dev/null +++ b/api/integrations/flagsmith/data/environment.json @@ -0,0 +1,34 @@ +{ + "api_key": "masked", + "feature_states": [ + { + "feature": { + "id": 51089, + "name": "test", + "type": "STANDARD" + }, + "enabled": false, + "django_id": 286268, + "feature_segment": null, + "featurestate_uuid": "ec33a926-0b7e-4eb7-b02b-bf9df2ffa53e", + "feature_state_value": null, + "multivariate_feature_state_values": [] + } + ], + "id": 0, + "name": "Development", + "project": { + "id": 0, + "name": "Flagsmith API", + "hide_disabled_flags": false, + "organisation": { + "id": 0, + "name": "Flagsmith", + "feature_analytics": false, + "stop_serving_flags": false, + "persist_trait_data": true + }, + "segments": [] + }, + "use_identity_composite_key_for_hashing": true +} \ No newline at end of file diff --git a/api/integrations/flagsmith/exceptions.py b/api/integrations/flagsmith/exceptions.py new file mode 100644 index 000000000000..e99c8f032671 --- /dev/null +++ b/api/integrations/flagsmith/exceptions.py @@ -0,0 +1,2 @@ +class FlagsmithIntegrationError(Exception): + pass diff --git a/api/integrations/flagsmith/flagsmith_service.py b/api/integrations/flagsmith/flagsmith_service.py new file mode 100644 index 000000000000..4e0998592e2a --- /dev/null +++ b/api/integrations/flagsmith/flagsmith_service.py @@ -0,0 +1,75 @@ +import json +import os + +import requests +from django.conf import settings + +from integrations.flagsmith.exceptions import FlagsmithIntegrationError + +ENVIRONMENT_JSON_PATH = os.path.join( + os.path.dirname(os.path.realpath(__file__)), "data/environment.json" +) + +KEEP_ENVIRONMENT_FIELDS = ( + "name", + "feature_states", + "use_identity_composite_key_for_hashing", +) +KEEP_PROJECT_FIELDS = ("name", "organisation", "hide_disabled_flags") +KEEP_ORGANISATION_FIELDS = ( + "name", + "feature_analytics", + "stop_serving_flags", + "persist_trait_data", +) + + +def update_environment_json(environment_key: str = None, api_url: str = None) -> None: + environment_key = environment_key or settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY + api_url = api_url or settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL + + response = requests.get( + f"{api_url}/environment-document", + headers={"X-Environment-Key": environment_key}, + ) + if response.status_code != 200: + raise FlagsmithIntegrationError( + f"Couldn't get defaults from Flagsmith. Got {response.status_code} response." + ) + + environment_json = _get_masked_environment_data(response.json()) + with open(ENVIRONMENT_JSON_PATH, "w+") as defaults: + defaults.write(json.dumps(environment_json, indent=2, sort_keys=True)) + + +def _get_masked_environment_data(environment_document: dict) -> dict: + """ + Return a cut down / masked version of the environment + document which can be committed to VCS. + """ + + project_json = environment_document.pop("project") + organisation_json = project_json.pop("organisation") + + return { + "id": 0, + "api_key": "masked", + **{ + k: v + for k, v in environment_document.items() + if k in KEEP_ENVIRONMENT_FIELDS + }, + "project": { + "id": 0, + **{k: v for k, v in project_json.items() if k in KEEP_PROJECT_FIELDS}, + "organisation": { + "id": 0, + **{ + k: v + for k, v in organisation_json.items() + if k in KEEP_ORGANISATION_FIELDS + }, + }, + "segments": [], + }, + } diff --git a/api/integrations/flagsmith/management/__init__.py b/api/integrations/flagsmith/management/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/flagsmith/management/commands/__init__.py b/api/integrations/flagsmith/management/commands/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/flagsmith/management/commands/updateflagsmithenvironment.py b/api/integrations/flagsmith/management/commands/updateflagsmithenvironment.py new file mode 100644 index 000000000000..b9f2942a484c --- /dev/null +++ b/api/integrations/flagsmith/management/commands/updateflagsmithenvironment.py @@ -0,0 +1,8 @@ +from django.core.management import BaseCommand + +from integrations.flagsmith.flagsmith_service import update_environment_json + + +class Command(BaseCommand): + def handle(self, *args, **options): + update_environment_json() diff --git a/api/poetry.lock b/api/poetry.lock index 9bf84b29e1a4..3409596a820d 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1320,6 +1320,21 @@ files = [ docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] +[[package]] +name = "flagsmith" +version = "3.4.0" +description = "Flagsmith Python SDK" +optional = false +python-versions = ">=3.7.0,<4" +files = [ + {file = "flagsmith-3.4.0.tar.gz", hash = "sha256:247ed4a225a5017bf805f137496dd5760361582c338b689db59e6c112e6e9281"}, +] + +[package.dependencies] +flagsmith-flag-engine = ">=4.0.0,<5.0.0" +requests = ">=2.27.1,<3.0.0" +requests-futures = ">=1.0.0,<2.0.0" + [[package]] name = "flagsmith-flag-engine" version = "4.0.4" @@ -3026,6 +3041,23 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-futures" +version = "1.0.1" +description = "Asynchronous Python HTTP for Humans." +optional = false +python-versions = "*" +files = [ + {file = "requests-futures-1.0.1.tar.gz", hash = "sha256:f55a4ef80070e2858e7d1e73123d2bfaeaf25b93fd34384d8ddf148e2b676373"}, + {file = "requests_futures-1.0.1-py2.py3-none-any.whl", hash = "sha256:4a2f5472e9911a79532137d156aa937cd9cd90fec55677f71b2976d1f7a66d38"}, +] + +[package.dependencies] +requests = ">=1.2.0" + +[package.extras] +dev = ["black (>=22.3.0)", "build (>=0.7.0)", "isort (>=5.11.4)", "pyflakes (>=2.2.0)", "pytest (>=6.2.5)", "pytest-cov (>=3.0.0)", "pytest-network (>=0.0.1)", "readme-renderer[rst] (>=26.0)", "twine (>=3.4.2)"] + [[package]] name = "requests-oauthlib" version = "1.3.1" @@ -3818,4 +3850,4 @@ requests = ">=2.7,<3.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "4c0ee22cfcd33a22205e6744f14c8766ec80de6f7ac70e4c27dc7a946a6811ee" +content-hash = "7a5d38c26e12869333a0085be9625b094e7f88b90fb8ac7390cbab032674d052" diff --git a/api/pyproject.toml b/api/pyproject.toml index 2024c4dc2726..e887f6612920 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -101,6 +101,7 @@ django-ses = "~3.5.0" django-axes = "~5.32.0" pydantic = "~1.10.9" pyngo = "~1.6.0" +flagsmith = "^3.4.0" [tool.poetry.group.auth-controller.dependencies] django-multiselectfield = "~0.1.12" diff --git a/api/tests/unit/integrations/flagsmith/__init__.py b/api/tests/unit/integrations/flagsmith/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_client.py b/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_client.py new file mode 100644 index 000000000000..1e7450c08cf2 --- /dev/null +++ b/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_client.py @@ -0,0 +1,102 @@ +from unittest.mock import MagicMock + +import pytest +from flagsmith.offline_handlers import LocalFileHandler +from pytest_django.fixtures import SettingsWrapper +from pytest_mock import MockerFixture + +from integrations.flagsmith.client import get_client +from integrations.flagsmith.exceptions import FlagsmithIntegrationError +from integrations.flagsmith.flagsmith_service import ENVIRONMENT_JSON_PATH + + +@pytest.fixture(autouse=True) +def reset_globals(mocker: MockerFixture) -> None: + mocker.patch("integrations.flagsmith.client._flagsmith_client", None) + yield + + +@pytest.fixture() +def mock_local_file_handler(mocker: MockerFixture) -> None: + return mocker.MagicMock(spec=LocalFileHandler) + + +@pytest.fixture() +def mock_local_file_handler_class( + mocker: MockerFixture, mock_local_file_handler: MagicMock +): + return mocker.patch( + "integrations.flagsmith.client.LocalFileHandler", + return_value=mock_local_file_handler, + ) + + +def test_get_client_initialises_flagsmith_with_correct_arguments_offline_mode_disabled( + settings: SettingsWrapper, + mocker: MockerFixture, + mock_local_file_handler, + mock_local_file_handler_class, +) -> None: + # Given + server_key = "some-key" + api_url = "https://my.flagsmith.api/api/v1/" + + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY = server_key + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL = api_url + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE = False + + mock_flagsmith_class = mocker.patch("integrations.flagsmith.client.Flagsmith") + + # When + client = get_client() + + # Then + assert client == mock_flagsmith_class.return_value + + mock_flagsmith_class.assert_called_once() + + call_args = mock_flagsmith_class.call_args + assert call_args.kwargs["environment_key"] == server_key + assert call_args.kwargs["api_url"] == api_url + assert "offline_mode" not in call_args.kwargs + assert call_args.kwargs["offline_handler"] == mock_local_file_handler + + mock_local_file_handler_class.assert_called_once_with(ENVIRONMENT_JSON_PATH) + + +def test_get_client_initialises_flagsmith_with_correct_arguments_offline_mode_enabled( + settings: SettingsWrapper, + mocker: MockerFixture, + mock_local_file_handler, + mock_local_file_handler_class, +) -> None: + # Given + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE = True + + mock_flagsmith_class = mocker.patch("integrations.flagsmith.client.Flagsmith") + + # When + client = get_client() + + # Then + assert client == mock_flagsmith_class.return_value + + mock_flagsmith_class.assert_called_once() + + call_args = mock_flagsmith_class.call_args + assert call_args.kwargs["offline_mode"] is True + assert call_args.kwargs["offline_handler"] == mock_local_file_handler + + mock_local_file_handler_class.assert_called_once_with(ENVIRONMENT_JSON_PATH) + + +def test_get_client_raises_value_error_if_missing_args( + settings: SettingsWrapper, mock_local_file_handler_class +): + # Given + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE = False + assert settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY is None + + # When + with pytest.raises(FlagsmithIntegrationError): + get_client() diff --git a/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_service.py b/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_service.py new file mode 100644 index 000000000000..fd5e81eeb178 --- /dev/null +++ b/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_service.py @@ -0,0 +1,106 @@ +import json +from unittest.mock import mock_open, patch + +import pytest +import responses + +from integrations.flagsmith.exceptions import FlagsmithIntegrationError +from integrations.flagsmith.flagsmith_service import update_environment_json + + +@pytest.fixture() +def environment_document(): + """ + An example environment document as returned from the API. + """ + return { + "id": 1, + "api_key": "some-key", + "project": { + "id": 1, + "name": "Test Project", + "organisation": { + "id": 1, + "name": "Test Organisation", + "feature_analytics": False, + "stop_serving_flags": False, + "persist_trait_data": True, + }, + "hide_disabled_flags": False, + "segments": [], + "enable_realtime_updates": False, + "server_key_only_feature_ids": [], + }, + "feature_states": [ + { + "feature": {"id": 1, "name": "test", "type": "STANDARD"}, + "enabled": False, + "django_id": 1, + "feature_segment": None, + "featurestate_uuid": "ec33a926-0b7e-4eb7-b02b-bf9df2ffa53e", + "feature_state_value": None, + "multivariate_feature_state_values": [], + } + ], + "name": "Test", + "allow_client_traits": True, + "updated_at": "2023-08-01T14:00:36.347565+00:00", + "hide_sensitive_data": False, + "hide_disabled_flags": None, + "use_identity_composite_key_for_hashing": True, + "amplitude_config": None, + "dynatrace_config": None, + "heap_config": None, + "mixpanel_config": None, + "rudderstack_config": None, + "segment_config": None, + "webhook_config": None, + } + + +@responses.activate +def test_update_environment_json(settings, environment_document): + """ + Test to verify that, when we call update_environment_json, the response is written + to the correct file and that the sensitive data from the response is masked. + """ + # Given + api_url = "https://api.flagsmith.com/api/v1" + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL = api_url + + responses.add( + method="GET", + url=f"{api_url}/environment-document", + body=json.dumps(environment_document), + status=200, + ) + + # When + with patch("builtins.open", mock_open(read_data="")) as mocked_open: + update_environment_json() + + # Then + written_json = json.loads(mocked_open.return_value.write.call_args[0][0]) + assert written_json["id"] == 0 + assert written_json["api_key"] == "masked" + assert written_json["feature_states"] == environment_document["feature_states"] + assert written_json["project"]["id"] == 0 + assert written_json["project"]["segments"] == [] + assert written_json["project"]["organisation"]["id"] == 0 + + +@responses.activate +def test_update_environment_json_throws_exception_for_failed_request(settings): + # Given + api_url = "https://api.flagsmith.com/api/v1" + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL = api_url + + responses.add( + method="GET", + url=f"{api_url}/environment-document", + status=404, + ) + + # When + with pytest.raises(FlagsmithIntegrationError): + update_environment_json()