From c6274ce223bd0f076bb9dd51dabe8ae43ce7b292 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Fri, 15 Mar 2024 14:25:06 +0000 Subject: [PATCH 01/42] WIP: licensing --- api/app/settings/common.py | 1 + api/organisations/models.py | 14 +++++-- .../subscriptions/licensing/__init__.py | 1 + .../subscriptions/licensing/licensing.py | 19 ++++++++++ .../licensing/migrations/0001_initial.py | 26 +++++++++++++ .../licensing/migrations/__init__.py | 0 .../subscriptions/licensing/models.py | 18 +++++++++ .../subscriptions/licensing/views.py | 20 ++++++++++ api/organisations/subscriptions/metadata.py | 4 +- api/organisations/urls.py | 9 +++++ .../test_unit_organisations_views.py | 38 +++++++++++++++++++ 11 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 api/organisations/subscriptions/licensing/__init__.py create mode 100644 api/organisations/subscriptions/licensing/licensing.py create mode 100644 api/organisations/subscriptions/licensing/migrations/0001_initial.py create mode 100644 api/organisations/subscriptions/licensing/migrations/__init__.py create mode 100644 api/organisations/subscriptions/licensing/models.py create mode 100644 api/organisations/subscriptions/licensing/views.py diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 1544fdc58923..804665d9deab 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -105,6 +105,7 @@ "organisations", "organisations.invites", "organisations.permissions", + "organisations.subscriptions.licensing", # TODO: should this be added conditionally? "projects", "sales_dashboard", "environments", diff --git a/api/organisations/models.py b/api/organisations/models.py index 1323b2c7162d..315b7a1ff0ef 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -398,10 +398,18 @@ def _get_subscription_metadata_for_self_hosted(self) -> BaseSubscriptionMetadata if not is_enterprise(): return FREE_PLAN_SUBSCRIPTION_METADATA + # TODO: this feels odd returning to the organisation that we likely just came + # from to get to this method. + if hasattr(self.organisation, "licence"): + licence_information = self.organisation.licence.get_licence_information() + return BaseSubscriptionMetadata( + seats=licence_information.num_seats, + projects=licence_information.num_projects, + ) + return BaseSubscriptionMetadata( - seats=self.max_seats, - api_calls=self.max_api_calls, - projects=None, + seats=MAX_SEATS_IN_FREE_PLAN, + projects=settings.MAX_PROJECTS_IN_FREE_PLAN, ) def add_single_seat(self): diff --git a/api/organisations/subscriptions/licensing/__init__.py b/api/organisations/subscriptions/licensing/__init__.py new file mode 100644 index 000000000000..b52d01ed6388 --- /dev/null +++ b/api/organisations/subscriptions/licensing/__init__.py @@ -0,0 +1 @@ +# TODO: split this into a private package? diff --git a/api/organisations/subscriptions/licensing/licensing.py b/api/organisations/subscriptions/licensing/licensing.py new file mode 100644 index 000000000000..926eff72c5fd --- /dev/null +++ b/api/organisations/subscriptions/licensing/licensing.py @@ -0,0 +1,19 @@ +import typing +from datetime import datetime + +from pydantic import BaseModel + + +class LicenceInformation(BaseModel): + organisation_name: str + plan_id: str + + department_name: typing.Optional[str] = None + expiry_date: typing.Optional[datetime] = None + + # TODO: should these live in a nested object? + num_seats: int + num_projects: int # TODO: what about Flagsmith on Flagsmith project? + num_api_calls: typing.Optional[ + int + ] = None # required to support private cloud installs diff --git a/api/organisations/subscriptions/licensing/migrations/0001_initial.py b/api/organisations/subscriptions/licensing/migrations/0001_initial.py new file mode 100644 index 000000000000..7dd1cfba83c0 --- /dev/null +++ b/api/organisations/subscriptions/licensing/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.24 on 2024-03-15 15:52 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organisations', '0052_create_hubspot_organisation'), + ] + + operations = [ + migrations.CreateModel( + name='OrganisationLicence', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('content', models.TextField(blank=True)), + ('organisation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='organisations.organisation')), + ], + ), + ] diff --git a/api/organisations/subscriptions/licensing/migrations/__init__.py b/api/organisations/subscriptions/licensing/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/organisations/subscriptions/licensing/models.py b/api/organisations/subscriptions/licensing/models.py new file mode 100644 index 000000000000..5f664976c184 --- /dev/null +++ b/api/organisations/subscriptions/licensing/models.py @@ -0,0 +1,18 @@ +from django.db import models + +from organisations.subscriptions.licensing.licensing import LicenceInformation + + +class OrganisationLicence(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + organisation = models.OneToOneField( + "organisations.Organisation", related_name="licence", on_delete=models.CASCADE + ) + + content = models.TextField(blank=True) + + def get_licence_information(self) -> LicenceInformation: + # TODO: decryption + return LicenceInformation.parse_raw(self.content) diff --git a/api/organisations/subscriptions/licensing/views.py b/api/organisations/subscriptions/licensing/views.py new file mode 100644 index 000000000000..d3a81e9635c6 --- /dev/null +++ b/api/organisations/subscriptions/licensing/views.py @@ -0,0 +1,20 @@ +from rest_framework import serializers +from rest_framework.decorators import api_view +from rest_framework.request import Request +from rest_framework.response import Response + +from organisations.subscriptions.licensing.models import OrganisationLicence + + +@api_view(http_method_names=["PUT"]) +def create_or_update_licence( + request: Request, organisation_id: int, **kwargs +) -> Response: + if "licence" not in request.FILES: + raise serializers.ValidationError("No licence file provided.") + + OrganisationLicence.objects.update_or_create( + organisation_id=organisation_id, + defaults={"content": request.FILES["licence"].read().decode("utf-8")}, + ) + return Response(200) diff --git a/api/organisations/subscriptions/metadata.py b/api/organisations/subscriptions/metadata.py index f897a3a01880..785f6a24127f 100644 --- a/api/organisations/subscriptions/metadata.py +++ b/api/organisations/subscriptions/metadata.py @@ -7,9 +7,9 @@ class BaseSubscriptionMetadata: def __init__( self, seats: int = 0, - api_calls: int = 0, + api_calls: typing.Optional[int] = None, projects: typing.Optional[int] = None, - chargebee_email=None, + chargebee_email: str = None, ): self.seats = seats self.api_calls = api_calls diff --git a/api/organisations/urls.py b/api/organisations/urls.py index 65130186b471..40661bde8612 100644 --- a/api/organisations/urls.py +++ b/api/organisations/urls.py @@ -34,6 +34,7 @@ UserOrganisationPermissionViewSet, UserPermissionGroupOrganisationPermissionViewSet, ) +from .subscriptions.licensing.views import create_or_update_licence router = routers.DefaultRouter() router.register(r"", views.OrganisationViewSet, basename="organisation") @@ -140,6 +141,14 @@ OrganisationAPIUsageNotificationView.as_view(), name="organisation-api-usage-notification", ), + # TODO: + # - is this the best url? + # - do we need to conditionally add this URL, or just raise exception if not valid in the view? + path( + "/licence", + create_or_update_licence, + name="create-or-update-licence", + ), ] if settings.IS_RBAC_INSTALLED: diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index 428ebb04c5d1..0802bc07ffb8 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -1,3 +1,4 @@ +import importlib import json from datetime import datetime, timedelta from typing import Type @@ -9,6 +10,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core import mail +from django.core.files.uploadedfile import SimpleUploadedFile from django.db.models import Model from django.urls import reverse from django.utils import timezone @@ -20,6 +22,7 @@ from rest_framework import status from rest_framework.test import APIClient, override_settings +import organisations.urls from environments.models import Environment from environments.permissions.models import UserEnvironmentPermission from features.models import Feature @@ -42,6 +45,7 @@ SUBSCRIPTION_BILLING_STATUS_ACTIVE, SUBSCRIPTION_BILLING_STATUS_DUNNING, ) +from organisations.subscriptions.licensing.models import OrganisationLicence from projects.models import Project, UserProjectPermission from segments.models import Segment from users.models import ( @@ -1873,3 +1877,37 @@ def test_validation_error_if_non_numeric_organisation_id( # Then assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_create_or_update_licence( + organisation: Organisation, admin_client: APIClient, mocker: MockerFixture +) -> None: + # Given + mocker.patch("organisations.urls.is_enterprise", return_value=True) + + importlib.reload(organisations.urls) + + url = reverse( + "api-v1:organisations:create-or-update-licence", args=[organisation.id] + ) + + licence_data = { + "organisation_name": "Test Organisation", + "plan": "Enterprise", + "num_seats": 20, + "num_projects": 3, + } + + licence = SimpleUploadedFile( + name="licence.txt", + content=json.dumps(licence_data).encode(), + content_type="text/plain", + ) + + # When + response = admin_client.put(url, data={"licence": licence}) + + # Then + assert response.status_code == status.HTTP_200_OK + + assert OrganisationLicence.objects.filter(organisation=organisation).exists() From a3262f4d83691f6d1b085cc7b25aa6c4adb35a31 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 16 May 2024 12:56:42 +0100 Subject: [PATCH 02/42] Tidying up method --- api/organisations/models.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/api/organisations/models.py b/api/organisations/models.py index 315b7a1ff0ef..912e3ec1b53e 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -395,22 +395,16 @@ def _get_subscription_metadata_for_chargebee(self) -> ChargebeeObjMetadata: return get_subscription_metadata_from_id(self.subscription_id) def _get_subscription_metadata_for_self_hosted(self) -> BaseSubscriptionMetadata: - if not is_enterprise(): - return FREE_PLAN_SUBSCRIPTION_METADATA - # TODO: this feels odd returning to the organisation that we likely just came # from to get to this method. - if hasattr(self.organisation, "licence"): + if is_enterprise() and hasattr(self.organisation, "licence"): licence_information = self.organisation.licence.get_licence_information() return BaseSubscriptionMetadata( seats=licence_information.num_seats, projects=licence_information.num_projects, ) - return BaseSubscriptionMetadata( - seats=MAX_SEATS_IN_FREE_PLAN, - projects=settings.MAX_PROJECTS_IN_FREE_PLAN, - ) + return FREE_PLAN_SUBSCRIPTION_METADATA def add_single_seat(self): if not self.can_auto_upgrade_seats: From 2bc0bf4e09cf500d3bc9ffe246ce538dfc60632c Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Wed, 30 Oct 2024 14:17:09 +0000 Subject: [PATCH 03/42] Fix typing --- api/organisations/subscriptions/metadata.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/api/organisations/subscriptions/metadata.py b/api/organisations/subscriptions/metadata.py index 785f6a24127f..1d2e561fbddb 100644 --- a/api/organisations/subscriptions/metadata.py +++ b/api/organisations/subscriptions/metadata.py @@ -1,15 +1,12 @@ -import typing - - class BaseSubscriptionMetadata: payment_source = None def __init__( self, seats: int = 0, - api_calls: typing.Optional[int] = None, - projects: typing.Optional[int] = None, - chargebee_email: str = None, + api_calls: None | int = None, + projects: None | int = None, + chargebee_email: None | str = None, ): self.seats = seats self.api_calls = api_calls From 8ce9abed351017fc634dee638a8677a1bf7ef564 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Wed, 30 Oct 2024 15:31:07 +0000 Subject: [PATCH 04/42] Remove unnecessary mock --- api/tests/unit/organisations/test_unit_organisations_views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index 7755922ff1de..f76504677ed9 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -1979,8 +1979,6 @@ def test_create_or_update_licence( organisation: Organisation, admin_client: APIClient, mocker: MockerFixture ) -> None: # Given - mocker.patch("organisations.urls.is_enterprise", return_value=True) - importlib.reload(organisations.urls) url = reverse( From c6885fe9b9697b26862fd81769d2482ddc6fa3f7 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Wed, 30 Oct 2024 15:54:00 +0000 Subject: [PATCH 05/42] Remove unnecessary code --- .../unit/organisations/test_unit_organisations_views.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index f76504677ed9..207bac208111 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -1,4 +1,3 @@ -import importlib import json from datetime import datetime, timedelta from typing import Type @@ -22,7 +21,6 @@ from rest_framework import status from rest_framework.test import APIClient, override_settings -import organisations.urls from environments.models import Environment from environments.permissions.models import UserEnvironmentPermission from features.models import Feature @@ -1976,11 +1974,9 @@ def test_validation_error_if_non_numeric_organisation_id( def test_create_or_update_licence( - organisation: Organisation, admin_client: APIClient, mocker: MockerFixture + organisation: Organisation, admin_client: APIClient ) -> None: # Given - importlib.reload(organisations.urls) - url = reverse( "api-v1:organisations:create-or-update-licence", args=[organisation.id] ) From 694e46604a84218b934c6f8a310bcc7c2225ddea Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Wed, 30 Oct 2024 15:54:35 +0000 Subject: [PATCH 06/42] Add related name to one to one relation --- ..._alter_organisationlicence_organisation.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 api/organisations/subscriptions/licensing/migrations/0002_alter_organisationlicence_organisation.py diff --git a/api/organisations/subscriptions/licensing/migrations/0002_alter_organisationlicence_organisation.py b/api/organisations/subscriptions/licensing/migrations/0002_alter_organisationlicence_organisation.py new file mode 100644 index 000000000000..f46b3dad4ede --- /dev/null +++ b/api/organisations/subscriptions/licensing/migrations/0002_alter_organisationlicence_organisation.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.16 on 2024-10-30 15:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("organisations", "0058_update_audit_and_history_limits_in_sub_cache"), + ("licensing", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="organisationlicence", + name="organisation", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="licence", + to="organisations.organisation", + ), + ), + ] From 32f75b35e2c7a73464a0a3a4c2fe9cc6f17969f6 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Thu, 31 Oct 2024 15:24:34 +0000 Subject: [PATCH 07/42] Add licence to test to load up the results --- .../versioning/test_unit_versioning_views.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index e0da742be6c0..07f4905415c2 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -33,6 +33,7 @@ Subscription, ) from organisations.subscriptions.constants import SubscriptionPlanFamily +from organisations.subscriptions.licensing.models import OrganisationLicence from projects.models import Project from projects.permissions import VIEW_PROJECT from segments.models import Segment @@ -1664,6 +1665,20 @@ def test_list_versions_returns_all_versions_for_enterprise_plan( mocker.patch("organisations.models.is_saas", return_value=is_saas) mocker.patch("organisations.models.is_enterprise", return_value=not is_saas) + if is_saas is False: + licence_content = { + "organisation_name": "Test Organisation", + "plan_id": "Enterprise", + "num_seats": 20, + "num_projects": 3, + "num_api_calls": 3_000_000, + } + + OrganisationLicence.objects.create( + organisation=environment_v2_versioning.project.organisation, + content=json.dumps(licence_content), + ) + # Let's set the subscription plan as start up subscription.plan = "enterprise" subscription.save() From 40f0e4420b30d8ee947ceb359531ffee6472f6f7 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Thu, 31 Oct 2024 15:47:29 +0000 Subject: [PATCH 08/42] Add licence content to test --- .../test_unit_organisations_models.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/api/tests/unit/organisations/test_unit_organisations_models.py b/api/tests/unit/organisations/test_unit_organisations_models.py index aa76002cd5cd..4fca33e054c7 100644 --- a/api/tests/unit/organisations/test_unit_organisations_models.py +++ b/api/tests/unit/organisations/test_unit_organisations_models.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timedelta from unittest import mock @@ -28,6 +29,7 @@ from organisations.subscriptions.exceptions import ( SubscriptionDoesNotSupportSeatUpgrade, ) +from organisations.subscriptions.licensing.models import OrganisationLicence from organisations.subscriptions.metadata import BaseSubscriptionMetadata from organisations.subscriptions.xero.metadata import XeroSubscriptionMetadata @@ -346,8 +348,8 @@ def test_organisation_subscription_get_subscription_metadata_returns_free_plan_m MAX_SEATS_IN_FREE_PLAN, settings.MAX_PROJECTS_IN_FREE_PLAN, ), - ("anything", "enterprise", 20, 20, None), - (TRIAL_SUBSCRIPTION_ID, "enterprise", 20, 20, None), + ("anything", "enterprise", 20, 20, 10), + (TRIAL_SUBSCRIPTION_ID, "enterprise", 20, 20, 10), ), ) def test_organisation_get_subscription_metadata_for_enterprise_self_hosted_licenses( @@ -356,7 +358,7 @@ def test_organisation_get_subscription_metadata_for_enterprise_self_hosted_licen plan: str, max_seats: int, expected_seats: int, - expected_projects: int | None, + expected_projects: int, mocker: MockerFixture, ) -> None: """ @@ -368,6 +370,20 @@ def test_organisation_get_subscription_metadata_for_enterprise_self_hosted_licen Subscription.objects.filter(organisation=organisation).update( subscription_id=subscription_id, plan=plan, max_seats=max_seats ) + + licence_content = { + "organisation_name": "Test Organisation", + "plan_id": "Enterprise", + "num_seats": max_seats, + "num_projects": expected_projects, + "num_api_calls": 3_000_000, + } + + OrganisationLicence.objects.create( + organisation=organisation, + content=json.dumps(licence_content), + ) + organisation.subscription.refresh_from_db() mocker.patch("organisations.models.is_saas", return_value=False) mocker.patch("organisations.models.is_enterprise", return_value=True) From 6c7bf8dac87819987f124016f7897676c573812f Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:15:48 +0000 Subject: [PATCH 09/42] Create licensing private key command --- .../commands/create_licensing_private_key.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 api/organisations/management/commands/create_licensing_private_key.py diff --git a/api/organisations/management/commands/create_licensing_private_key.py b/api/organisations/management/commands/create_licensing_private_key.py new file mode 100644 index 000000000000..93482364f1b3 --- /dev/null +++ b/api/organisations/management/commands/create_licensing_private_key.py @@ -0,0 +1,10 @@ +from typing import Any + +from django.core.management import BaseCommand + +from organisations.subscriptions.licensing.helpers import create_private_key + + +class Command(BaseCommand): + def handle(self, *args: Any, **options: Any) -> None: + print(create_private_key()) From 8a0297124ef8c2291ec411f32c1468c16332da58 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:16:16 +0000 Subject: [PATCH 10/42] Create licensing public key command --- .../management/commands/create_licensing_public_key.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 api/organisations/management/commands/create_licensing_public_key.py diff --git a/api/organisations/management/commands/create_licensing_public_key.py b/api/organisations/management/commands/create_licensing_public_key.py new file mode 100644 index 000000000000..7eedf76c88fd --- /dev/null +++ b/api/organisations/management/commands/create_licensing_public_key.py @@ -0,0 +1,10 @@ +from typing import Any + +from django.core.management import BaseCommand + +from organisations.subscriptions.licensing.helpers import create_public_key + + +class Command(BaseCommand): + def handle(self, *args: Any, **options: Any) -> None: + print(create_public_key()) From 766432e6b4d82af03211278968ca39b3e300ad47 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:18:26 +0000 Subject: [PATCH 11/42] Create licensing cryptographic helpers --- .../subscriptions/licensing/helpers.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 api/organisations/subscriptions/licensing/helpers.py diff --git a/api/organisations/subscriptions/licensing/helpers.py b/api/organisations/subscriptions/licensing/helpers.py new file mode 100644 index 000000000000..b86687753985 --- /dev/null +++ b/api/organisations/subscriptions/licensing/helpers.py @@ -0,0 +1,91 @@ +import base64 +import logging + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from django.conf import settings + +logger: logging.Logger = logging.getLogger(name=__name__) + + +def sign_licence(licence: str) -> str: + message = licence.encode("utf-8") + + # Load the private key from PEM + private_key = serialization.load_pem_private_key( + settings.SUBSCRIPTION_LICENCE_PRIVATE_KEY.encode("utf-8"), password=None + ) + + # Sign the message using the private key + signature = private_key.sign( + message, + padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH + ), + hashes.SHA256(), + ) + + return base64.b64encode(signature).decode("utf-8") + + +def verify_signature(licence: str, licence_signature: str) -> bool: + signature = base64.b64decode(licence_signature) + public_key = serialization.load_pem_public_key( + settings.SUBSCRIPTION_LICENCE_PUBLIC_KEY.encode("utf-8") + ) + + try: + public_key.verify( + signature, + licence.encode("utf-8"), + padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH + ), + hashes.SHA256(), + ) + except Exception: + logger.error("Licence signature failed", exc_info=True) + return False + + return True + + +def create_public_key() -> str: + """ + Creates a public key from the private key that's set in settings. + """ + + # Load the private key from the UTF-8 PEM string. + private_key = serialization.load_pem_private_key( + settings.SUBSCRIPTION_LICENCE_PRIVATE_KEY.encode("utf-8"), + password=None, + backend=default_backend(), + ) + + # Extract the public key from the private key. + public_key = private_key.public_key() + + # Encode the public key to PEM format + public_key_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + return public_key_pem.decode("utf-8") + + +def create_private_key() -> str: + private_key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend() + ) + + # Convert the private key to PEM format as a byte string + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + # Print the PEM-encoded private key to standard output + return private_key_pem.decode("utf-8") From dcc573b6eb525583afc681d79076f3606e55266c Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:18:54 +0000 Subject: [PATCH 12/42] Test signatures --- .../subscriptions/licensing/test_helpers.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 api/tests/unit/organisations/subscriptions/licensing/test_helpers.py diff --git a/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py b/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py new file mode 100644 index 000000000000..0887611d4fc0 --- /dev/null +++ b/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py @@ -0,0 +1,27 @@ +import json + +from organisations.subscriptions.licensing.helpers import ( + sign_licence, + verify_signature, +) + + +def test_sign_and_verify_signature_of_licence() -> None: + # Given + licence_content = { + "organisation_name": "Test Organisation", + "plan_id": "Enterprise", + "num_seats": 20, + "num_projects": 3, + "num_api_calls": 3_000_000, + } + + licence = json.dumps(licence_content) + + # When + licence_signature = sign_licence(licence) + signature_verification = verify_signature(licence, licence_signature) + + # Then + assert licence_signature + assert signature_verification is True From d762817b1893dbe0b08f6a902e64514582f0e4b6 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:19:28 +0000 Subject: [PATCH 13/42] Update licence upload with signatures and test for missing files --- .../test_unit_organisations_views.py | 87 ++++++++++++++++++- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index 207bac208111..4eb6207c17ec 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -46,6 +46,7 @@ SUBSCRIPTION_BILLING_STATUS_ACTIVE, SUBSCRIPTION_BILLING_STATUS_DUNNING, ) +from organisations.subscriptions.licensing.helpers import sign_licence from organisations.subscriptions.licensing.models import OrganisationLicence from projects.models import Project, UserProjectPermission from segments.models import Segment @@ -1983,21 +1984,101 @@ def test_create_or_update_licence( licence_data = { "organisation_name": "Test Organisation", - "plan": "Enterprise", + "plan_id": "Enterprise", "num_seats": 20, "num_projects": 3, } + licence_json = json.dumps(licence_data) licence = SimpleUploadedFile( name="licence.txt", - content=json.dumps(licence_data).encode(), + content=licence_json.encode(), content_type="text/plain", ) + licence_signature = SimpleUploadedFile( + name="licence_signature.txt", + content=sign_licence(licence_json).encode(), + content_type="text/plain", + ) # When - response = admin_client.put(url, data={"licence": licence}) + response = admin_client.put( + url, + data={ + "licence": licence, + "licence_signature": licence_signature, + }, + ) # Then assert response.status_code == status.HTTP_200_OK assert OrganisationLicence.objects.filter(organisation=organisation).exists() + organisation_licence = OrganisationLicence.objects.get(organisation=organisation) + licence = organisation_licence.get_licence_information() + assert licence.num_seats == 20 + assert licence.num_projects == 3 + + +def test_create_or_update_licence_missing_licence( + organisation: Organisation, admin_client: APIClient +) -> None: + # Given + url = reverse( + "api-v1:organisations:create-or-update-licence", args=[organisation.id] + ) + + licence_signature = SimpleUploadedFile( + name="licence_signature.txt", + content=sign_licence("{}").encode(), + content_type="text/plain", + ) + # When + response = admin_client.put( + url, + data={ + "licence_signature": licence_signature, + }, + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == ["No licence file provided."] + + assert not OrganisationLicence.objects.filter(organisation=organisation).exists() + + +def test_create_or_update_licence_missing_licence_signature( + organisation: Organisation, admin_client: APIClient +) -> None: + # Given + url = reverse( + "api-v1:organisations:create-or-update-licence", args=[organisation.id] + ) + + licence_data = { + "organisation_name": "Test Organisation", + "plan_id": "Enterprise", + "num_seats": 20, + "num_projects": 3, + } + + licence_json = json.dumps(licence_data) + licence = SimpleUploadedFile( + name="licence.txt", + content=licence_json.encode(), + content_type="text/plain", + ) + + # When + response = admin_client.put( + url, + data={ + "licence": licence, + }, + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == ["No licence signature file provided."] + assert not OrganisationLicence.objects.filter(organisation=organisation).exists() From 7028bf776aafb4e3ee1ceccebdda3f69d58a9f32 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:20:06 +0000 Subject: [PATCH 14/42] Add cryptography to pyproject.toml explicitly --- api/poetry.lock | 61 +++++++++++++++++++++++----------------------- api/pyproject.toml | 1 + 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index a9160aeebb48..bb9d9255e53a 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -565,38 +565,38 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "43.0.1" +version = "43.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, - {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, - {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, - {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, - {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, - {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, - {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, ] [package.dependencies] @@ -609,7 +609,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -3338,7 +3338,6 @@ files = [ {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_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {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"}, @@ -4182,4 +4181,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "27333f5bbd3bb607cdb7d728dae6d0a6a11658cba09b45d69a6ee5a744111ad5" +content-hash = "8c3814124a9f0fe6d8abd13282bb417d930a5a01b0994b6f321ba77aab9827e1" diff --git a/api/pyproject.toml b/api/pyproject.toml index aaac71259708..adc9b1d795ba 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -173,6 +173,7 @@ flagsmith-task-processor = { git = "https://github.com/Flagsmith/flagsmith-task- flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.0.0" } tzdata = "^2024.1" djangorestframework-simplejwt = "^5.3.1" +cryptography = "^43.0.3" [tool.poetry.group.auth-controller] optional = true From 220d59a43be5902850be610dfa36006c86619cb7 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:20:37 +0000 Subject: [PATCH 15/42] Remove TODO --- api/organisations/urls.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/organisations/urls.py b/api/organisations/urls.py index 35196d1888f5..2125f4e5582d 100644 --- a/api/organisations/urls.py +++ b/api/organisations/urls.py @@ -153,9 +153,6 @@ OrganisationAPIUsageNotificationView.as_view(), name="organisation-api-usage-notification", ), - # TODO: - # - is this the best url? - # - do we need to conditionally add this URL, or just raise exception if not valid in the view? path( "/licence", create_or_update_licence, From 3789647ce70ec90ca0f4070d40b0c69ba428a125 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:21:02 +0000 Subject: [PATCH 16/42] Add signatures to endpoint --- .../subscriptions/licensing/views.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/api/organisations/subscriptions/licensing/views.py b/api/organisations/subscriptions/licensing/views.py index d3a81e9635c6..3bc6f0bf4b48 100644 --- a/api/organisations/subscriptions/licensing/views.py +++ b/api/organisations/subscriptions/licensing/views.py @@ -3,6 +3,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from organisations.subscriptions.licensing.helpers import verify_signature from organisations.subscriptions.licensing.models import OrganisationLicence @@ -13,8 +14,17 @@ def create_or_update_licence( if "licence" not in request.FILES: raise serializers.ValidationError("No licence file provided.") - OrganisationLicence.objects.update_or_create( - organisation_id=organisation_id, - defaults={"content": request.FILES["licence"].read().decode("utf-8")}, - ) + if "licence_signature" not in request.FILES: + raise serializers.ValidationError("No licence signature file provided.") + + licence = request.FILES["licence"].read().decode("utf-8") + licence_signature = request.FILES["licence_signature"].read().decode("utf-8") + + if verify_signature(licence, licence_signature): + OrganisationLicence.objects.update_or_create( + organisation_id=organisation_id, + defaults={"content": licence}, + ) + else: + raise serializers.ValidationError("Signature failed for licence.") return Response(200) From fbc5e90e7316460e7e713ff2c7b45391e7afbcbf Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:21:32 +0000 Subject: [PATCH 17/42] TODO finished --- api/organisations/subscriptions/licensing/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/organisations/subscriptions/licensing/models.py b/api/organisations/subscriptions/licensing/models.py index 5f664976c184..867c19839b4f 100644 --- a/api/organisations/subscriptions/licensing/models.py +++ b/api/organisations/subscriptions/licensing/models.py @@ -14,5 +14,4 @@ class OrganisationLicence(models.Model): content = models.TextField(blank=True) def get_licence_information(self) -> LicenceInformation: - # TODO: decryption return LicenceInformation.parse_raw(self.content) From 280307c0c568a7e829535283fc99c6dbd52f2647 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:21:51 +0000 Subject: [PATCH 18/42] Remove TODO as it's odd but ok --- api/organisations/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/organisations/models.py b/api/organisations/models.py index cde3d9701b91..467e7bad9b45 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -415,8 +415,6 @@ def _get_subscription_metadata_for_chargebee(self) -> ChargebeeObjMetadata: return cb_metadata def _get_subscription_metadata_for_self_hosted(self) -> BaseSubscriptionMetadata: - # TODO: this feels odd returning to the organisation that we likely just came - # from to get to this method. if is_enterprise() and hasattr(self.organisation, "licence"): licence_information = self.organisation.licence.get_licence_information() return BaseSubscriptionMetadata( From d7597a92c4a14e1f2ba91fb3230503bf17b9e8c2 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:22:30 +0000 Subject: [PATCH 19/42] Add key settings and remove TODO --- api/app/settings/common.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 3c41e9731585..807e64de1e92 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -106,7 +106,7 @@ "organisations", "organisations.invites", "organisations.permissions", - "organisations.subscriptions.licensing", # TODO: should this be added conditionally? + "organisations.subscriptions.licensing", "projects", "sales_dashboard", "edge_api", @@ -1272,3 +1272,6 @@ # subscriptions created before this date full audit log and versioning # history. VERSIONING_RELEASE_DATE = env.date("VERSIONING_RELEASE_DATE", default=None) + +SUBSCRIPTION_LICENCE_PUBLIC_KEY = env.str("SUBSCRIPTION_LICENCE_PUBLIC_KEY", None) +SUBSCRIPTION_LICENCE_PRIVATE_KEY = env.str("SUBSCRIPTION_LICENCE_PRIVATE_KEY", None) From e7788d679ea4bb9abf00afd302faa5abc84a43ef Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:22:59 +0000 Subject: [PATCH 20/42] Add test keys to test settings --- api/app/settings/test.py | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/api/app/settings/test.py b/api/app/settings/test.py index 1fb33fe5c0a4..3faad310c58f 100644 --- a/api/app/settings/test.py +++ b/api/app/settings/test.py @@ -18,3 +18,46 @@ RETRY_WEBHOOKS = True INFLUXDB_BUCKET = "test_bucket" + +SUBSCRIPTION_LICENCE_PRIVATE_KEY = """ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0o6Q+J6ArJZ2x +RyZQ5e9ue6dB4bgH7I7DYYb9t9eIb55z0vZZWVLLmIr+ngCfCxIePqCclrAen9gr +rCRhyAXD+XZYjRP0w2wlqA367HJXbti1adXnQnM4QXITNJhRnGoqiRVx7vQ/Klup ++yMBJOU4IkkSsQaAgp0eTdPlGlA+KAfCH39rsqIHNXuS1qfspI2RyaR6130NvR6D +4p07XJls1AYOs8xphdWl8b4hzbJTvC0IqRhvX+z4kEyQjprdcfwOG4qrqtIb4asm +21imOtE8CGRvHUl/cV+1l/hgv1fdbeCFzM89q16Z/KXIAWJMYfkWuOWGVEmf7yjB +9aMrfM3fAgMBAAECggEAKqGwQocBkw1GoS8kiNUrY8zFFZRa5Wvb6ZqbzEdWE7oc +EEPKph2hn7E5pIvPo7luJjsrlqktmZyp3Oy8jWMykSTP3Gg3PH3eiSiXXA/vkFj1 +xiLbO8AAB1fSv1ubUy9yEuXVbNUzSbEKfxxpD30Qp+XXjxS+bxfkUuGVT62dIH3V +j251CEsCIZzwOriGP52OKK5HR24Y9/c+uGLu1CLY6qdrMgWAXTYqEoUw7ku8Sm8B +o6fuu9i0mAEJUl6qcVz3yH0QYe9pM6jDQ9oZeSkVYyspCwysTVs2jsCTMYUpK/kD +WU9sniHRgly3C9Ge3PrE4qUeMRTNk0Vd4RETtznwqQKBgQD3UiJ11FUd3g1FXL9N +iLOIayACrX37cBuc8M5iAzasNDjSVNoCrQun1091xzYK6/F7As4PtH1YUaDgXdBp +efHHl3DPTFkeztPMOKOd8tCpLai/23sbCBNc51x0LCuWWnNaAuidId1rpvFq7AMJ +jE4HPJkoL6udOzlKUHebp02ILQKBgQC6+nT2A+AZeheUiw2wBl0BRitQCxA+TN+L +vkAwLa0u/OqeNc8W50lybzHCS9nEVZ0Lp3Qk9Cl++X/k5o6k2byqJxtmMvLGqjjw +UNuZWHSoUzfdzs8yBjroLM4HsBgbEaG9E2e2zuqKBvwLqZ3fv/fXvmJDIu+aCWXC +ADtlrAvJuwKBgQDq+CW1PJ4BWk3RcGRwDUhEe0JWSO5ATCpv2Hi7tcHjqVmyutrF +YBKKy4y6oSE/DxrFe8y6LwhHOIZXo8m17B1BOyf6StcA5g9jHwyTq3WCxdZlMOis +red3hHfaB30Bw72D7u+BGgN7m4gRxVi9YYdgaLo569Bn+TRc3kZEo5aNoQKBgH7z +aJBU50ZFCFeZ5iw61dD0pJnPOTMjnLBT917+1FRP8riCzl29obep2b4TJANTIbL0 ++j3Q7Y/BtV1kUTuKfreEn+zO8NmEX+6C5+cBEQvsnMTkEvfjFQHo0eaUYHmYihlH +YKbVbJdU0LLWclOmEpAQOsVcphQPB2EmKS4KF2LbAoGAOqVsQg61S1u7s4NF4JCN +EiJvBDjjwTycNCmhY7bV1R7LX+Qk/Mq9fgK3yccKV/Bl69C9Fmeopivbu20urNhn +q/sgOPDK0zJUSVh76gFon1gx7OfaHV31TrvIl0T7WnyfDvAv20F+dmmXkjnPBNNm +dXzo4kXwDOlWCJI8VhYfH/0= +-----END PRIVATE KEY----- +""" + +SUBSCRIPTION_LICENCE_PUBLIC_KEY = """ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtKOkPiegKyWdsUcmUOXv +bnunQeG4B+yOw2GG/bfXiG+ec9L2WVlSy5iK/p4AnwsSHj6gnJawHp/YK6wkYcgF +w/l2WI0T9MNsJagN+uxyV27YtWnV50JzOEFyEzSYUZxqKokVce70PypbqfsjASTl +OCJJErEGgIKdHk3T5RpQPigHwh9/a7KiBzV7ktan7KSNkcmketd9Db0eg+KdO1yZ +bNQGDrPMaYXVpfG+Ic2yU7wtCKkYb1/s+JBMkI6a3XH8DhuKq6rSG+GrJttYpjrR +PAhkbx1Jf3FftZf4YL9X3W3ghczPPatemfylyAFiTGH5FrjlhlRJn+8owfWjK3zN +3wIDAQAB +-----END PUBLIC KEY----- +""" From ecab02c4f30364e31888da5807441dd4d3357bc4 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Fri, 1 Nov 2024 19:25:35 +0000 Subject: [PATCH 21/42] Remove nested object TODO --- api/organisations/subscriptions/licensing/licensing.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/api/organisations/subscriptions/licensing/licensing.py b/api/organisations/subscriptions/licensing/licensing.py index 926eff72c5fd..5f4afd6adcdf 100644 --- a/api/organisations/subscriptions/licensing/licensing.py +++ b/api/organisations/subscriptions/licensing/licensing.py @@ -11,9 +11,8 @@ class LicenceInformation(BaseModel): department_name: typing.Optional[str] = None expiry_date: typing.Optional[datetime] = None - # TODO: should these live in a nested object? num_seats: int num_projects: int # TODO: what about Flagsmith on Flagsmith project? - num_api_calls: typing.Optional[ - int - ] = None # required to support private cloud installs + num_api_calls: typing.Optional[int] = ( + None # required to support private cloud installs + ) From c5dd2a244e5287e7673301264fcf6f6f1bfc3cb8 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 4 Nov 2024 14:11:59 +0000 Subject: [PATCH 22/42] Add __init__.py --- api/tests/unit/organisations/subscriptions/licensing/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 api/tests/unit/organisations/subscriptions/licensing/__init__.py diff --git a/api/tests/unit/organisations/subscriptions/licensing/__init__.py b/api/tests/unit/organisations/subscriptions/licensing/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 From 6754529ee5b72b06ad3649b7716590eab05b0e04 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 4 Nov 2024 15:10:30 +0000 Subject: [PATCH 23/42] Create signed licence management command --- .../commands/create_signed_licence.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 api/organisations/management/commands/create_signed_licence.py diff --git a/api/organisations/management/commands/create_signed_licence.py b/api/organisations/management/commands/create_signed_licence.py new file mode 100644 index 000000000000..25ed9e5be104 --- /dev/null +++ b/api/organisations/management/commands/create_signed_licence.py @@ -0,0 +1,74 @@ +import argparse +import json +from typing import Any + +from django.core.management import BaseCommand + +from organisations.subscriptions.licensing.helpers import sign_licence + + +class Command(BaseCommand): + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--organisation-name", + type=str, + dest="organisation_name", + help="Name of the organisation", + ) + parser.add_argument( + "--plan-id", + type=str, + dest="plan_id", + help="Plan id for the organisation", + ) + parser.add_argument( + "--num-seats", + type=int, + dest="num_seats", + help="Number of seats available to the organisation", + default=1, + ) + parser.add_argument( + "--num-projects", + type=int, + dest="num_projects", + help="Number of projects available to the organisation", + default=1, + ) + parser.add_argument( + "--num-api-calls", + type=int, + dest="num_api_calls", + help="Number of API calls available to the organisation", + default=1_000_000, + ) + + def handle( + self, + *args: Any, + organisation_name: str, + plan_id: str, + num_seats: int, + num_projects: int, + num_api_calls: int, + **options: Any, + ) -> None: + print( + "Don't forget to increment the project count by 1 to " + "account for Flagsmith on Flagsmith projects." + ) + licence_content = { + "organisation_name": organisation_name, + "plan_id": plan_id, + "num_seats": num_seats, + "num_projects": num_projects, + "num_api_calls": num_api_calls, + } + + licence = json.dumps(licence_content) + licence_signature = sign_licence(licence) + + print("Here is the licence:") + print(licence) + print("Here is the signature:") + print(licence_signature) From 3d75ba141aaa05f2bc73b3c6964959fdb1b0246a Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 4 Nov 2024 15:52:49 +0000 Subject: [PATCH 24/42] Add test for failed signature --- .../test_unit_organisations_views.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index 4eb6207c17ec..b83968183b18 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -2082,3 +2082,61 @@ def test_create_or_update_licence_missing_licence_signature( assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == ["No licence signature file provided."] assert not OrganisationLicence.objects.filter(organisation=organisation).exists() + + +def test_create_or_update_licence_bad_signature( + organisation: Organisation, + admin_client: APIClient, + settings: SettingsWrapper, +) -> None: + # Given + url = reverse( + "api-v1:organisations:create-or-update-licence", args=[organisation.id] + ) + + licence_data = { + "organisation_name": "Test Organisation", + "plan_id": "Enterprise", + "num_seats": 20, + "num_projects": 3, + } + + licence_json = json.dumps(licence_data) + licence = SimpleUploadedFile( + name="licence.txt", + content=licence_json.encode(), + content_type="text/plain", + ) + licence_signature = SimpleUploadedFile( + name="licence_signature.txt", + content=sign_licence(licence_json).encode(), + content_type="text/plain", + ) + + # Change the public key information so the signature fails + settings.SUBSCRIPTION_LICENCE_PUBLIC_KEY = """ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtKOkPiegKyWdsUcmUOXv +bnunQeG4B+yOw2GG/bfXiG+ec9L2WVlSy5iK/p4AnwsSHj6gnJawHp/YK6wkYcgF +w/l2WI0T9MNsJagN+uxyV27YtWnV50JzOEFyEzSYUZxqKokVce70PypbqfsjASTl +OCJJErEGgIKdHk3T5RpQPigHwh9/a7KiBzV7ktan7KSNkcmketd9Db0eg+KdO1yZ +bNQGDrPMaYXVpfG+Ic2yU7wtCKkYb1/s+JBMkI6a3XH8DhuKq6rSG+GrJttYpjrR +PAhkbx1Jf3FftZf4YL9X3W3ghczPPatemfylyAFiTGH5FrjlhlRJn+8owfWjK3zN +3wIDAQAC +-----END PUBLIC KEY----- + """ + + # When + response = admin_client.put( + url, + data={ + "licence": licence, + "licence_signature": licence_signature, + }, + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == ["Signature failed for licence."] + + assert not OrganisationLicence.objects.filter(organisation=organisation).exists() From 5fcd0d377d16a574f72e09090ed909346418595a Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 4 Nov 2024 15:53:18 +0000 Subject: [PATCH 25/42] Add signature tests for test helpers --- .../subscriptions/licensing/test_helpers.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py b/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py index 0887611d4fc0..e0c122fc024c 100644 --- a/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py +++ b/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py @@ -1,6 +1,10 @@ import json +from pytest_django.fixtures import SettingsWrapper + from organisations.subscriptions.licensing.helpers import ( + create_private_key, + create_public_key, sign_licence, verify_signature, ) @@ -25,3 +29,54 @@ def test_sign_and_verify_signature_of_licence() -> None: # Then assert licence_signature assert signature_verification is True + + +def test_sign_and_verify_signature_of_licence_when_signature_fails( + settings: SettingsWrapper, +) -> None: + # Given + # Change the public key information so the signature fails + settings.SUBSCRIPTION_LICENCE_PUBLIC_KEY = """ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtKOkPiegKyWdsUcmUOXv +bnunQeG4B+yOw2GG/bfXiG+ec9L2WVlSy5iK/p4AnwsSHj6gnJawHp/YK6wkYcgF +w/l2WI0T9MNsJagN+uxyV27YtWnV50JzOEFyEzSYUZxqKokVce70PypbqfsjASTl +OCJJErEGgIKdHk3T5RpQPigHwh9/a7KiBzV7ktan7KSNkcmketd9Db0eg+KdO1yZ +bNQGDrPMaYXVpfG+Ic2yU7wtCKkYb1/s+JBMkI6a3XH8DhuKq6rSG+GrJttYpjrR +PAhkbx1Jf3FftZf4YL9X3W3ghczPPatemfylyAFiTGH5FrjlhlRJn+8owfWjK3zN +3wIDAQAC +-----END PUBLIC KEY----- + """ + + licence_content = { + "organisation_name": "Test Organisation", + "plan_id": "Enterprise", + "num_seats": 20, + "num_projects": 3, + "num_api_calls": 3_000_000, + } + + licence = json.dumps(licence_content) + + # When + licence_signature = sign_licence(licence) + signature_verification = verify_signature(licence, licence_signature) + + # Then + assert signature_verification is False + + +def test_create_public_key() -> None: + # Given / When + public_key = create_public_key() + + # Then + assert "BEGIN PUBLIC KEY" in public_key + + +def test_create_private_key() -> None: + # Given / When + private_key = create_private_key() + + # Then + assert "BEGIN PRIVATE KEY" in private_key From 462646ac6f0101f832dcf82b2dd13dbd7e0ae3c5 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 4 Nov 2024 16:37:43 +0000 Subject: [PATCH 26/42] Remove TODO --- api/organisations/subscriptions/licensing/licensing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/organisations/subscriptions/licensing/licensing.py b/api/organisations/subscriptions/licensing/licensing.py index 5f4afd6adcdf..c5c1b92f1036 100644 --- a/api/organisations/subscriptions/licensing/licensing.py +++ b/api/organisations/subscriptions/licensing/licensing.py @@ -12,7 +12,7 @@ class LicenceInformation(BaseModel): expiry_date: typing.Optional[datetime] = None num_seats: int - num_projects: int # TODO: what about Flagsmith on Flagsmith project? + num_projects: int num_api_calls: typing.Optional[int] = ( None # required to support private cloud installs ) From 67e873882ba951c7e7c685e984e1056464782991 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 4 Nov 2024 16:51:58 +0000 Subject: [PATCH 27/42] Add a default public key for digital signature checking --- api/app/settings/common.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 807e64de1e92..1495f13517bb 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -1273,5 +1273,21 @@ # history. VERSIONING_RELEASE_DATE = env.date("VERSIONING_RELEASE_DATE", default=None) -SUBSCRIPTION_LICENCE_PUBLIC_KEY = env.str("SUBSCRIPTION_LICENCE_PUBLIC_KEY", None) +SUBSCRIPTION_LICENCE_PUBLIC_KEY = env.str( + "SUBSCRIPTION_LICENCE_PUBLIC_KEY", + """ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs1H23Xv1IlhyTbUP9Z4e +zN3t6oa97ybLufhqSRCoPxHWAY/pqqjdwiC00AnRbL/guDi1FLPEkLza2gAKfU+f +04SsNTfYL5MTPnaFtf+B+hlYmlrT1C6n05t+uQW2OQm6mWoqBssmoyR8T5FXfBls +FrT8dsZg5XG7JaWAyGbbVscHrXHXqVcLbFGO8CcO2BG2whl+7hzm4edNCsxLJqmN +uASR9KtntdulkRar0A9x+hAQUlrDKv77nMMdljNIqkcCcWrbhiDoTVCDbE99mhMq +LeC/+C54/ZiCb3r9woq/kpsbRj0Ys2b4czfjWioXooSxA0w3BE6/lV0+hVltjRO6 +5QIDAQAB +-----END PUBLIC KEY----- +""", +) + +# For the matching private key to the public key added above +# search for "Flagsmith licence private key" in Bitwarden. SUBSCRIPTION_LICENCE_PRIVATE_KEY = env.str("SUBSCRIPTION_LICENCE_PRIVATE_KEY", None) From f4fba4e487982110b30abe2360a6bdb121b5722d Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Tue, 5 Nov 2024 16:21:19 +0000 Subject: [PATCH 28/42] Move second migration into 0001_initial.py --- .../licensing/migrations/0001_initial.py | 31 ++++++++++++++----- ..._alter_organisationlicence_organisation.py | 24 -------------- 2 files changed, 23 insertions(+), 32 deletions(-) delete mode 100644 api/organisations/subscriptions/licensing/migrations/0002_alter_organisationlicence_organisation.py diff --git a/api/organisations/subscriptions/licensing/migrations/0001_initial.py b/api/organisations/subscriptions/licensing/migrations/0001_initial.py index 7dd1cfba83c0..3ef03156ffad 100644 --- a/api/organisations/subscriptions/licensing/migrations/0001_initial.py +++ b/api/organisations/subscriptions/licensing/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.24 on 2024-03-15 15:52 +# Generated by Django 4.2.16 on 2024-11-05 15:39 from django.db import migrations, models import django.db.models.deletion @@ -9,18 +9,33 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('organisations', '0052_create_hubspot_organisation'), + ("organisations", "0058_update_audit_and_history_limits_in_sub_cache"), ] operations = [ migrations.CreateModel( - name='OrganisationLicence', + name="OrganisationLicence", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('content', models.TextField(blank=True)), - ('organisation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='organisations.organisation')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("content", models.TextField(blank=True)), + ( + "organisation", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="licence", + to="organisations.organisation", + ), + ), ], ), ] diff --git a/api/organisations/subscriptions/licensing/migrations/0002_alter_organisationlicence_organisation.py b/api/organisations/subscriptions/licensing/migrations/0002_alter_organisationlicence_organisation.py deleted file mode 100644 index f46b3dad4ede..000000000000 --- a/api/organisations/subscriptions/licensing/migrations/0002_alter_organisationlicence_organisation.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.2.16 on 2024-10-30 15:40 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("organisations", "0058_update_audit_and_history_limits_in_sub_cache"), - ("licensing", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="organisationlicence", - name="organisation", - field=models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="licence", - to="organisations.organisation", - ), - ), - ] From 8904e7e1422d7e1494a76651736b8fbfcba06033 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Tue, 5 Nov 2024 16:22:32 +0000 Subject: [PATCH 29/42] Switch to Django stdout and add exception handling for a missing private key --- .../commands/create_licensing_private_key.py | 2 +- .../commands/create_licensing_public_key.py | 2 +- .../commands/create_signed_licence.py | 31 +++++++++++++------ 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/api/organisations/management/commands/create_licensing_private_key.py b/api/organisations/management/commands/create_licensing_private_key.py index 93482364f1b3..04738f70f8e7 100644 --- a/api/organisations/management/commands/create_licensing_private_key.py +++ b/api/organisations/management/commands/create_licensing_private_key.py @@ -7,4 +7,4 @@ class Command(BaseCommand): def handle(self, *args: Any, **options: Any) -> None: - print(create_private_key()) + self.stdout.write(create_private_key()) diff --git a/api/organisations/management/commands/create_licensing_public_key.py b/api/organisations/management/commands/create_licensing_public_key.py index 7eedf76c88fd..604f24383ee2 100644 --- a/api/organisations/management/commands/create_licensing_public_key.py +++ b/api/organisations/management/commands/create_licensing_public_key.py @@ -7,4 +7,4 @@ class Command(BaseCommand): def handle(self, *args: Any, **options: Any) -> None: - print(create_public_key()) + self.stdout.write(create_public_key()) diff --git a/api/organisations/management/commands/create_signed_licence.py b/api/organisations/management/commands/create_signed_licence.py index 25ed9e5be104..1b91e3a77b44 100644 --- a/api/organisations/management/commands/create_signed_licence.py +++ b/api/organisations/management/commands/create_signed_licence.py @@ -4,7 +4,10 @@ from django.core.management import BaseCommand -from organisations.subscriptions.licensing.helpers import sign_licence +from organisations.subscriptions.licensing.helpers import ( + PrivateKeyMissingError, + sign_licence, +) class Command(BaseCommand): @@ -53,9 +56,11 @@ def handle( num_api_calls: int, **options: Any, ) -> None: - print( - "Don't forget to increment the project count by 1 to " - "account for Flagsmith on Flagsmith projects." + self.stdout.write( + self.style.NOTICE( + "Don't forget to increment the project count by 1 to " + "account for Flagsmith on Flagsmith projects." + ) ) licence_content = { "organisation_name": organisation_name, @@ -66,9 +71,17 @@ def handle( } licence = json.dumps(licence_content) - licence_signature = sign_licence(licence) - print("Here is the licence:") - print(licence) - print("Here is the signature:") - print(licence_signature) + try: + licence_signature = sign_licence(licence) + except PrivateKeyMissingError: + self.stdout.write( + self.style.NOTICE( + "Missing settings.SUBSCRIPTION_LICENCE_PRIVATE_KEY to sign licence. It can be found in Bitwarden." + ) + ) + + self.stdout.write(self.style.SUCCESS("Here is the licence:")) + self.stdout.write(licence) + self.stdout.write(self.style.SUCCESS("Here is the signature:")) + self.stdout.write(licence_signature) From 47c374ffb5b1bd6200aac6576078b77b2bfb2cc1 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Tue, 5 Nov 2024 16:23:23 +0000 Subject: [PATCH 30/42] Create test for missing private key --- .../subscriptions/licensing/test_helpers.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py b/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py index e0c122fc024c..dcc44f03358c 100644 --- a/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py +++ b/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py @@ -1,8 +1,10 @@ import json +import pytest from pytest_django.fixtures import SettingsWrapper from organisations.subscriptions.licensing.helpers import ( + PrivateKeyMissingError, create_private_key, create_public_key, sign_licence, @@ -66,6 +68,27 @@ def test_sign_and_verify_signature_of_licence_when_signature_fails( assert signature_verification is False +def test_sign_licence_with_missing_private_key(settings: SettingsWrapper) -> None: + # Given + settings.SUBSCRIPTION_LICENCE_PRIVATE_KEY = None + licence_content = { + "organisation_name": "Test Organisation", + "plan_id": "Enterprise", + "num_seats": 20, + "num_projects": 3, + "num_api_calls": 3_000_000, + } + + licence = json.dumps(licence_content) + + # When + with pytest.raises(PrivateKeyMissingError) as exception: + sign_licence(licence) + + # Then + assert str(exception.value) == "Private key is missing" + + def test_create_public_key() -> None: # Given / When public_key = create_public_key() From 449daceb6406cb0b25e2681c597dc194594716e9 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Tue, 5 Nov 2024 16:24:05 +0000 Subject: [PATCH 31/42] Split parameterized test into two separate tests --- .../versioning/test_unit_versioning_views.py | 105 ++++++++++++++---- 1 file changed, 85 insertions(+), 20 deletions(-) diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index 07f4905415c2..3104f964910b 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -1640,8 +1640,7 @@ def test_list_versions_always_returns_current_version_even_if_outside_limit( @pytest.mark.freeze_time(now - timedelta(days=DEFAULT_VERSION_LIMIT_DAYS + 1)) -@pytest.mark.parametrize("is_saas", (True, False)) -def test_list_versions_returns_all_versions_for_enterprise_plan( +def test_list_versions_returns_all_versions_for_enterprise_plan_when_saas( feature: Feature, environment_v2_versioning: Environment, staff_user: FFAdminUser, @@ -1650,10 +1649,10 @@ def test_list_versions_returns_all_versions_for_enterprise_plan( with_project_permissions: WithProjectPermissionsCallable, subscription: Subscription, freezer: FrozenDateTimeFactory, - is_saas: bool, mocker: MockerFixture, ) -> None: # Given + is_saas = True with_environment_permissions([VIEW_ENVIRONMENT]) with_project_permissions([VIEW_PROJECT]) @@ -1665,30 +1664,96 @@ def test_list_versions_returns_all_versions_for_enterprise_plan( mocker.patch("organisations.models.is_saas", return_value=is_saas) mocker.patch("organisations.models.is_enterprise", return_value=not is_saas) - if is_saas is False: - licence_content = { - "organisation_name": "Test Organisation", - "plan_id": "Enterprise", - "num_seats": 20, - "num_projects": 3, - "num_api_calls": 3_000_000, - } + # Let's set the subscription plan as start up + subscription.plan = "enterprise" + subscription.save() - OrganisationLicence.objects.create( - organisation=environment_v2_versioning.project.organisation, - content=json.dumps(licence_content), + OrganisationSubscriptionInformationCache.objects.update_or_create( + organisation=subscription.organisation, + defaults={"feature_history_visibility_days": None}, + ) + + initial_version = EnvironmentFeatureVersion.objects.get( + feature=feature, environment=environment_v2_versioning + ) + + # First, let's create some versions at the frozen time which is + # outside the limit allowed when using the scale up plan (but + # shouldn't matter to the enterprise plan. + all_versions = [] + for _ in range(3): + version = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature ) + version.publish(staff_user) + all_versions.append(version) + + # Now let's jump to the current time and create some versions which + # are inside the limit when using the startup plan + freezer.move_to(now) + + for _ in range(3): + version = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + version.publish(staff_user) + all_versions.append(version) + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["count"] == 7 # we created 6, plus the original version + assert {v["uuid"] for v in response_json["results"]} == { + str(v.uuid) for v in [initial_version, *all_versions] + } + + +@pytest.mark.freeze_time(now - timedelta(days=DEFAULT_VERSION_LIMIT_DAYS + 1)) +def test_list_versions_returns_all_versions_for_enterprise_plan_when_not_saas( + feature: Feature, + environment_v2_versioning: Environment, + staff_user: FFAdminUser, + staff_client: APIClient, + with_environment_permissions: WithEnvironmentPermissionsCallable, + with_project_permissions: WithProjectPermissionsCallable, + subscription: Subscription, + freezer: FrozenDateTimeFactory, + mocker: MockerFixture, +) -> None: + # Given + with_environment_permissions([VIEW_ENVIRONMENT]) + with_project_permissions([VIEW_PROJECT]) + is_saas = False + + url = reverse( + "api-v1:versioning:environment-feature-versions-list", + args=[environment_v2_versioning.id, feature.id], + ) + + mocker.patch("organisations.models.is_saas", return_value=is_saas) + mocker.patch("organisations.models.is_enterprise", return_value=not is_saas) + + licence_content = { + "organisation_name": "Test Organisation", + "plan_id": "Enterprise", + "num_seats": 20, + "num_projects": 3, + "num_api_calls": 3_000_000, + } + + OrganisationLicence.objects.create( + organisation=environment_v2_versioning.project.organisation, + content=json.dumps(licence_content), + ) # Let's set the subscription plan as start up subscription.plan = "enterprise" subscription.save() - if is_saas: - OrganisationSubscriptionInformationCache.objects.update_or_create( - organisation=subscription.organisation, - defaults={"feature_history_visibility_days": None}, - ) - initial_version = EnvironmentFeatureVersion.objects.get( feature=feature, environment=environment_v2_versioning ) From cfc3e28464c98004fa9e351e7dd39c4a0f3c23f9 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Tue, 5 Nov 2024 16:24:53 +0000 Subject: [PATCH 32/42] Raise an exception if private key is missing --- api/organisations/subscriptions/licensing/helpers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/api/organisations/subscriptions/licensing/helpers.py b/api/organisations/subscriptions/licensing/helpers.py index b86687753985..9cf8e2eb65ef 100644 --- a/api/organisations/subscriptions/licensing/helpers.py +++ b/api/organisations/subscriptions/licensing/helpers.py @@ -9,9 +9,16 @@ logger: logging.Logger = logging.getLogger(name=__name__) +class PrivateKeyMissingError(RuntimeError): + pass + + def sign_licence(licence: str) -> str: message = licence.encode("utf-8") + if not settings.SUBSCRIPTION_LICENCE_PRIVATE_KEY: + raise PrivateKeyMissingError("Private key is missing") + # Load the private key from PEM private_key = serialization.load_pem_private_key( settings.SUBSCRIPTION_LICENCE_PRIVATE_KEY.encode("utf-8"), password=None From f90a45a468ac04ac60b5d1939dcadf0e0f15479e Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 11 Nov 2024 16:15:45 +0000 Subject: [PATCH 33/42] Remove managment commands from organisations --- .../commands/create_licensing_private_key.py | 10 --- .../commands/create_licensing_public_key.py | 10 --- .../commands/create_signed_licence.py | 87 ------------------- 3 files changed, 107 deletions(-) delete mode 100644 api/organisations/management/commands/create_licensing_private_key.py delete mode 100644 api/organisations/management/commands/create_licensing_public_key.py delete mode 100644 api/organisations/management/commands/create_signed_licence.py diff --git a/api/organisations/management/commands/create_licensing_private_key.py b/api/organisations/management/commands/create_licensing_private_key.py deleted file mode 100644 index 04738f70f8e7..000000000000 --- a/api/organisations/management/commands/create_licensing_private_key.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Any - -from django.core.management import BaseCommand - -from organisations.subscriptions.licensing.helpers import create_private_key - - -class Command(BaseCommand): - def handle(self, *args: Any, **options: Any) -> None: - self.stdout.write(create_private_key()) diff --git a/api/organisations/management/commands/create_licensing_public_key.py b/api/organisations/management/commands/create_licensing_public_key.py deleted file mode 100644 index 604f24383ee2..000000000000 --- a/api/organisations/management/commands/create_licensing_public_key.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Any - -from django.core.management import BaseCommand - -from organisations.subscriptions.licensing.helpers import create_public_key - - -class Command(BaseCommand): - def handle(self, *args: Any, **options: Any) -> None: - self.stdout.write(create_public_key()) diff --git a/api/organisations/management/commands/create_signed_licence.py b/api/organisations/management/commands/create_signed_licence.py deleted file mode 100644 index 1b91e3a77b44..000000000000 --- a/api/organisations/management/commands/create_signed_licence.py +++ /dev/null @@ -1,87 +0,0 @@ -import argparse -import json -from typing import Any - -from django.core.management import BaseCommand - -from organisations.subscriptions.licensing.helpers import ( - PrivateKeyMissingError, - sign_licence, -) - - -class Command(BaseCommand): - def add_arguments(self, parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--organisation-name", - type=str, - dest="organisation_name", - help="Name of the organisation", - ) - parser.add_argument( - "--plan-id", - type=str, - dest="plan_id", - help="Plan id for the organisation", - ) - parser.add_argument( - "--num-seats", - type=int, - dest="num_seats", - help="Number of seats available to the organisation", - default=1, - ) - parser.add_argument( - "--num-projects", - type=int, - dest="num_projects", - help="Number of projects available to the organisation", - default=1, - ) - parser.add_argument( - "--num-api-calls", - type=int, - dest="num_api_calls", - help="Number of API calls available to the organisation", - default=1_000_000, - ) - - def handle( - self, - *args: Any, - organisation_name: str, - plan_id: str, - num_seats: int, - num_projects: int, - num_api_calls: int, - **options: Any, - ) -> None: - self.stdout.write( - self.style.NOTICE( - "Don't forget to increment the project count by 1 to " - "account for Flagsmith on Flagsmith projects." - ) - ) - licence_content = { - "organisation_name": organisation_name, - "plan_id": plan_id, - "num_seats": num_seats, - "num_projects": num_projects, - "num_api_calls": num_api_calls, - } - - licence = json.dumps(licence_content) - - try: - licence_signature = sign_licence(licence) - except PrivateKeyMissingError: - self.stdout.write( - self.style.NOTICE( - "Missing settings.SUBSCRIPTION_LICENCE_PRIVATE_KEY to sign licence. It can be found in Bitwarden." - ) - ) - - self.stdout.write(self.style.SUCCESS("Here is the licence:")) - self.stdout.write(licence) - self.stdout.write(self.style.SUCCESS("Here is the signature:")) - self.stdout.write(licence_signature) From 26976022cde0769103ed5ff0986d1fa963be491c Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 11 Nov 2024 16:16:30 +0000 Subject: [PATCH 34/42] Remove licensing module --- .../subscriptions/licensing/__init__.py | 1 - .../subscriptions/licensing/helpers.py | 98 ------------------- .../subscriptions/licensing/licensing.py | 18 ---- .../licensing/migrations/0001_initial.py | 41 -------- .../licensing/migrations/__init__.py | 0 .../subscriptions/licensing/models.py | 17 ---- .../subscriptions/licensing/views.py | 30 ------ 7 files changed, 205 deletions(-) delete mode 100644 api/organisations/subscriptions/licensing/__init__.py delete mode 100644 api/organisations/subscriptions/licensing/helpers.py delete mode 100644 api/organisations/subscriptions/licensing/licensing.py delete mode 100644 api/organisations/subscriptions/licensing/migrations/0001_initial.py delete mode 100644 api/organisations/subscriptions/licensing/migrations/__init__.py delete mode 100644 api/organisations/subscriptions/licensing/models.py delete mode 100644 api/organisations/subscriptions/licensing/views.py diff --git a/api/organisations/subscriptions/licensing/__init__.py b/api/organisations/subscriptions/licensing/__init__.py deleted file mode 100644 index b52d01ed6388..000000000000 --- a/api/organisations/subscriptions/licensing/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: split this into a private package? diff --git a/api/organisations/subscriptions/licensing/helpers.py b/api/organisations/subscriptions/licensing/helpers.py deleted file mode 100644 index 9cf8e2eb65ef..000000000000 --- a/api/organisations/subscriptions/licensing/helpers.py +++ /dev/null @@ -1,98 +0,0 @@ -import base64 -import logging - -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import padding, rsa -from django.conf import settings - -logger: logging.Logger = logging.getLogger(name=__name__) - - -class PrivateKeyMissingError(RuntimeError): - pass - - -def sign_licence(licence: str) -> str: - message = licence.encode("utf-8") - - if not settings.SUBSCRIPTION_LICENCE_PRIVATE_KEY: - raise PrivateKeyMissingError("Private key is missing") - - # Load the private key from PEM - private_key = serialization.load_pem_private_key( - settings.SUBSCRIPTION_LICENCE_PRIVATE_KEY.encode("utf-8"), password=None - ) - - # Sign the message using the private key - signature = private_key.sign( - message, - padding.PSS( - mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH - ), - hashes.SHA256(), - ) - - return base64.b64encode(signature).decode("utf-8") - - -def verify_signature(licence: str, licence_signature: str) -> bool: - signature = base64.b64decode(licence_signature) - public_key = serialization.load_pem_public_key( - settings.SUBSCRIPTION_LICENCE_PUBLIC_KEY.encode("utf-8") - ) - - try: - public_key.verify( - signature, - licence.encode("utf-8"), - padding.PSS( - mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH - ), - hashes.SHA256(), - ) - except Exception: - logger.error("Licence signature failed", exc_info=True) - return False - - return True - - -def create_public_key() -> str: - """ - Creates a public key from the private key that's set in settings. - """ - - # Load the private key from the UTF-8 PEM string. - private_key = serialization.load_pem_private_key( - settings.SUBSCRIPTION_LICENCE_PRIVATE_KEY.encode("utf-8"), - password=None, - backend=default_backend(), - ) - - # Extract the public key from the private key. - public_key = private_key.public_key() - - # Encode the public key to PEM format - public_key_pem = public_key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - - return public_key_pem.decode("utf-8") - - -def create_private_key() -> str: - private_key = rsa.generate_private_key( - public_exponent=65537, key_size=2048, backend=default_backend() - ) - - # Convert the private key to PEM format as a byte string - private_key_pem = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - - # Print the PEM-encoded private key to standard output - return private_key_pem.decode("utf-8") diff --git a/api/organisations/subscriptions/licensing/licensing.py b/api/organisations/subscriptions/licensing/licensing.py deleted file mode 100644 index c5c1b92f1036..000000000000 --- a/api/organisations/subscriptions/licensing/licensing.py +++ /dev/null @@ -1,18 +0,0 @@ -import typing -from datetime import datetime - -from pydantic import BaseModel - - -class LicenceInformation(BaseModel): - organisation_name: str - plan_id: str - - department_name: typing.Optional[str] = None - expiry_date: typing.Optional[datetime] = None - - num_seats: int - num_projects: int - num_api_calls: typing.Optional[int] = ( - None # required to support private cloud installs - ) diff --git a/api/organisations/subscriptions/licensing/migrations/0001_initial.py b/api/organisations/subscriptions/licensing/migrations/0001_initial.py deleted file mode 100644 index 3ef03156ffad..000000000000 --- a/api/organisations/subscriptions/licensing/migrations/0001_initial.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 4.2.16 on 2024-11-05 15:39 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("organisations", "0058_update_audit_and_history_limits_in_sub_cache"), - ] - - operations = [ - migrations.CreateModel( - name="OrganisationLicence", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("content", models.TextField(blank=True)), - ( - "organisation", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="licence", - to="organisations.organisation", - ), - ), - ], - ), - ] diff --git a/api/organisations/subscriptions/licensing/migrations/__init__.py b/api/organisations/subscriptions/licensing/migrations/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/api/organisations/subscriptions/licensing/models.py b/api/organisations/subscriptions/licensing/models.py deleted file mode 100644 index 867c19839b4f..000000000000 --- a/api/organisations/subscriptions/licensing/models.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.db import models - -from organisations.subscriptions.licensing.licensing import LicenceInformation - - -class OrganisationLicence(models.Model): - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - organisation = models.OneToOneField( - "organisations.Organisation", related_name="licence", on_delete=models.CASCADE - ) - - content = models.TextField(blank=True) - - def get_licence_information(self) -> LicenceInformation: - return LicenceInformation.parse_raw(self.content) diff --git a/api/organisations/subscriptions/licensing/views.py b/api/organisations/subscriptions/licensing/views.py deleted file mode 100644 index 3bc6f0bf4b48..000000000000 --- a/api/organisations/subscriptions/licensing/views.py +++ /dev/null @@ -1,30 +0,0 @@ -from rest_framework import serializers -from rest_framework.decorators import api_view -from rest_framework.request import Request -from rest_framework.response import Response - -from organisations.subscriptions.licensing.helpers import verify_signature -from organisations.subscriptions.licensing.models import OrganisationLicence - - -@api_view(http_method_names=["PUT"]) -def create_or_update_licence( - request: Request, organisation_id: int, **kwargs -) -> Response: - if "licence" not in request.FILES: - raise serializers.ValidationError("No licence file provided.") - - if "licence_signature" not in request.FILES: - raise serializers.ValidationError("No licence signature file provided.") - - licence = request.FILES["licence"].read().decode("utf-8") - licence_signature = request.FILES["licence_signature"].read().decode("utf-8") - - if verify_signature(licence, licence_signature): - OrganisationLicence.objects.update_or_create( - organisation_id=organisation_id, - defaults={"content": licence}, - ) - else: - raise serializers.ValidationError("Signature failed for licence.") - return Response(200) From 6cedd7f81431be60d131f3b7a74a7c2b8e17dc77 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 11 Nov 2024 16:16:56 +0000 Subject: [PATCH 35/42] Optionally add licensing to installed apps --- api/app/settings/common.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 1495f13517bb..c8f6c6004504 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -106,7 +106,6 @@ "organisations", "organisations.invites", "organisations.permissions", - "organisations.subscriptions.licensing", "projects", "sales_dashboard", "edge_api", @@ -1291,3 +1290,8 @@ # For the matching private key to the public key added above # search for "Flagsmith licence private key" in Bitwarden. SUBSCRIPTION_LICENCE_PRIVATE_KEY = env.str("SUBSCRIPTION_LICENCE_PRIVATE_KEY", None) + +LICENSING_INSTALLED = importlib.util.find_spec("licensing") is not None + +if LICENSING_INSTALLED: + INSTALLED_APPS.append("licensing") From cc880bfceaeba319413d20ef65271e9237dc0577 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 11 Nov 2024 16:17:27 +0000 Subject: [PATCH 36/42] Remove cryptography from dependencies --- api/poetry.lock | 2 +- api/pyproject.toml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index bb9d9255e53a..8b6dbe131edb 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -4181,4 +4181,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "8c3814124a9f0fe6d8abd13282bb417d930a5a01b0994b6f321ba77aab9827e1" +content-hash = "27333f5bbd3bb607cdb7d728dae6d0a6a11658cba09b45d69a6ee5a744111ad5" diff --git a/api/pyproject.toml b/api/pyproject.toml index adc9b1d795ba..aaac71259708 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -173,7 +173,6 @@ flagsmith-task-processor = { git = "https://github.com/Flagsmith/flagsmith-task- flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.0.0" } tzdata = "^2024.1" djangorestframework-simplejwt = "^5.3.1" -cryptography = "^43.0.3" [tool.poetry.group.auth-controller] optional = true From 91f14230a74b76f1e691181af1324d4bc8fad141 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 11 Nov 2024 16:18:00 +0000 Subject: [PATCH 37/42] Add check if licensing isn't present --- api/organisations/models.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/api/organisations/models.py b/api/organisations/models.py index 467e7bad9b45..3789f6a17236 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -423,6 +423,14 @@ def _get_subscription_metadata_for_self_hosted(self) -> BaseSubscriptionMetadata audit_log_visibility_days=None, feature_history_visibility_days=None, ) + elif is_enterprise(): + return BaseSubscriptionMetadata( + seats=self.max_seats, + api_calls=self.max_api_calls, + projects=None, + audit_log_visibility_days=None, + feature_history_visibility_days=None, + ) return FREE_PLAN_SUBSCRIPTION_METADATA From 2408dc84c1ea54f799da315bb24cc860716cb1fd Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 11 Nov 2024 16:18:33 +0000 Subject: [PATCH 38/42] Make licensing path optional --- api/organisations/urls.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/api/organisations/urls.py b/api/organisations/urls.py index 2125f4e5582d..c6bb85650032 100644 --- a/api/organisations/urls.py +++ b/api/organisations/urls.py @@ -35,7 +35,6 @@ UserOrganisationPermissionViewSet, UserPermissionGroupOrganisationPermissionViewSet, ) -from .subscriptions.licensing.views import create_or_update_licence router = routers.DefaultRouter() router.register(r"", views.OrganisationViewSet, basename="organisation") @@ -153,13 +152,22 @@ OrganisationAPIUsageNotificationView.as_view(), name="organisation-api-usage-notification", ), - path( - "/licence", - create_or_update_licence, - name="create-or-update-licence", - ), ] +if settings.LICENSING_INSTALLED: + from licensing.views import create_or_update_licence + + urlpatterns.extend( + [ + path( + "/licence", + create_or_update_licence, + name="create-or-update-licence", + ), + ] + ) + + if settings.IS_RBAC_INSTALLED: from rbac.views import ( GroupRoleViewSet, From b8a31e96f11ea50fdfe9baa91d700f06e0c23768 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 11 Nov 2024 16:20:46 +0000 Subject: [PATCH 39/42] Remove licensing tests into the new repo --- .../versioning/test_unit_versioning_views.py | 82 --------- .../test_unit_organisations_models.py | 60 ------ .../test_unit_organisations_views.py | 171 ------------------ 3 files changed, 313 deletions(-) diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index 3104f964910b..c5607e8f84af 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -33,7 +33,6 @@ Subscription, ) from organisations.subscriptions.constants import SubscriptionPlanFamily -from organisations.subscriptions.licensing.models import OrganisationLicence from projects.models import Project from projects.permissions import VIEW_PROJECT from segments.models import Segment @@ -1710,84 +1709,3 @@ def test_list_versions_returns_all_versions_for_enterprise_plan_when_saas( assert {v["uuid"] for v in response_json["results"]} == { str(v.uuid) for v in [initial_version, *all_versions] } - - -@pytest.mark.freeze_time(now - timedelta(days=DEFAULT_VERSION_LIMIT_DAYS + 1)) -def test_list_versions_returns_all_versions_for_enterprise_plan_when_not_saas( - feature: Feature, - environment_v2_versioning: Environment, - staff_user: FFAdminUser, - staff_client: APIClient, - with_environment_permissions: WithEnvironmentPermissionsCallable, - with_project_permissions: WithProjectPermissionsCallable, - subscription: Subscription, - freezer: FrozenDateTimeFactory, - mocker: MockerFixture, -) -> None: - # Given - with_environment_permissions([VIEW_ENVIRONMENT]) - with_project_permissions([VIEW_PROJECT]) - is_saas = False - - url = reverse( - "api-v1:versioning:environment-feature-versions-list", - args=[environment_v2_versioning.id, feature.id], - ) - - mocker.patch("organisations.models.is_saas", return_value=is_saas) - mocker.patch("organisations.models.is_enterprise", return_value=not is_saas) - - licence_content = { - "organisation_name": "Test Organisation", - "plan_id": "Enterprise", - "num_seats": 20, - "num_projects": 3, - "num_api_calls": 3_000_000, - } - - OrganisationLicence.objects.create( - organisation=environment_v2_versioning.project.organisation, - content=json.dumps(licence_content), - ) - - # Let's set the subscription plan as start up - subscription.plan = "enterprise" - subscription.save() - - initial_version = EnvironmentFeatureVersion.objects.get( - feature=feature, environment=environment_v2_versioning - ) - - # First, let's create some versions at the frozen time which is - # outside the limit allowed when using the scale up plan (but - # shouldn't matter to the enterprise plan. - all_versions = [] - for _ in range(3): - version = EnvironmentFeatureVersion.objects.create( - environment=environment_v2_versioning, feature=feature - ) - version.publish(staff_user) - all_versions.append(version) - - # Now let's jump to the current time and create some versions which - # are inside the limit when using the startup plan - freezer.move_to(now) - - for _ in range(3): - version = EnvironmentFeatureVersion.objects.create( - environment=environment_v2_versioning, feature=feature - ) - version.publish(staff_user) - all_versions.append(version) - - # When - response = staff_client.get(url) - - # Then - assert response.status_code == status.HTTP_200_OK - - response_json = response.json() - assert response_json["count"] == 7 # we created 6, plus the original version - assert {v["uuid"] for v in response_json["results"]} == { - str(v.uuid) for v in [initial_version, *all_versions] - } diff --git a/api/tests/unit/organisations/test_unit_organisations_models.py b/api/tests/unit/organisations/test_unit_organisations_models.py index 4fca33e054c7..a3ace82a8e31 100644 --- a/api/tests/unit/organisations/test_unit_organisations_models.py +++ b/api/tests/unit/organisations/test_unit_organisations_models.py @@ -1,4 +1,3 @@ -import json from datetime import datetime, timedelta from unittest import mock @@ -29,7 +28,6 @@ from organisations.subscriptions.exceptions import ( SubscriptionDoesNotSupportSeatUpgrade, ) -from organisations.subscriptions.licensing.models import OrganisationLicence from organisations.subscriptions.metadata import BaseSubscriptionMetadata from organisations.subscriptions.xero.metadata import XeroSubscriptionMetadata @@ -338,64 +336,6 @@ def test_organisation_subscription_get_subscription_metadata_returns_free_plan_m assert subscription_metadata == FREE_PLAN_SUBSCRIPTION_METADATA -@pytest.mark.parametrize( - "subscription_id, plan, max_seats, expected_seats, expected_projects", - ( - ( - None, - "free", - 10, - MAX_SEATS_IN_FREE_PLAN, - settings.MAX_PROJECTS_IN_FREE_PLAN, - ), - ("anything", "enterprise", 20, 20, 10), - (TRIAL_SUBSCRIPTION_ID, "enterprise", 20, 20, 10), - ), -) -def test_organisation_get_subscription_metadata_for_enterprise_self_hosted_licenses( - organisation: Organisation, - subscription_id: str | None, - plan: str, - max_seats: int, - expected_seats: int, - expected_projects: int, - mocker: MockerFixture, -) -> None: - """ - Specific test to make sure that we can manually add subscriptions to - enterprise self-hosted deployments and the values stored in the django - database will be correctly used. - """ - # Given - Subscription.objects.filter(organisation=organisation).update( - subscription_id=subscription_id, plan=plan, max_seats=max_seats - ) - - licence_content = { - "organisation_name": "Test Organisation", - "plan_id": "Enterprise", - "num_seats": max_seats, - "num_projects": expected_projects, - "num_api_calls": 3_000_000, - } - - OrganisationLicence.objects.create( - organisation=organisation, - content=json.dumps(licence_content), - ) - - organisation.subscription.refresh_from_db() - mocker.patch("organisations.models.is_saas", return_value=False) - mocker.patch("organisations.models.is_enterprise", return_value=True) - - # When - subscription_metadata = organisation.subscription.get_subscription_metadata() - - # Then - assert subscription_metadata.projects == expected_projects - assert subscription_metadata.seats == expected_seats - - @pytest.mark.parametrize( "subscription_id, plan, max_seats, max_api_calls, expected_seats, " "expected_api_calls, expected_projects", diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index b83968183b18..d55b28e4fb20 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -9,7 +9,6 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core import mail -from django.core.files.uploadedfile import SimpleUploadedFile from django.db.models import Model from django.urls import reverse from django.utils import timezone @@ -46,8 +45,6 @@ SUBSCRIPTION_BILLING_STATUS_ACTIVE, SUBSCRIPTION_BILLING_STATUS_DUNNING, ) -from organisations.subscriptions.licensing.helpers import sign_licence -from organisations.subscriptions.licensing.models import OrganisationLicence from projects.models import Project, UserProjectPermission from segments.models import Segment from users.models import ( @@ -1972,171 +1969,3 @@ def test_validation_error_if_non_numeric_organisation_id( # Then assert response.status_code == status.HTTP_400_BAD_REQUEST - - -def test_create_or_update_licence( - organisation: Organisation, admin_client: APIClient -) -> None: - # Given - url = reverse( - "api-v1:organisations:create-or-update-licence", args=[organisation.id] - ) - - licence_data = { - "organisation_name": "Test Organisation", - "plan_id": "Enterprise", - "num_seats": 20, - "num_projects": 3, - } - - licence_json = json.dumps(licence_data) - licence = SimpleUploadedFile( - name="licence.txt", - content=licence_json.encode(), - content_type="text/plain", - ) - - licence_signature = SimpleUploadedFile( - name="licence_signature.txt", - content=sign_licence(licence_json).encode(), - content_type="text/plain", - ) - # When - response = admin_client.put( - url, - data={ - "licence": licence, - "licence_signature": licence_signature, - }, - ) - - # Then - assert response.status_code == status.HTTP_200_OK - - assert OrganisationLicence.objects.filter(organisation=organisation).exists() - organisation_licence = OrganisationLicence.objects.get(organisation=organisation) - licence = organisation_licence.get_licence_information() - assert licence.num_seats == 20 - assert licence.num_projects == 3 - - -def test_create_or_update_licence_missing_licence( - organisation: Organisation, admin_client: APIClient -) -> None: - # Given - url = reverse( - "api-v1:organisations:create-or-update-licence", args=[organisation.id] - ) - - licence_signature = SimpleUploadedFile( - name="licence_signature.txt", - content=sign_licence("{}").encode(), - content_type="text/plain", - ) - # When - response = admin_client.put( - url, - data={ - "licence_signature": licence_signature, - }, - ) - - # Then - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json() == ["No licence file provided."] - - assert not OrganisationLicence.objects.filter(organisation=organisation).exists() - - -def test_create_or_update_licence_missing_licence_signature( - organisation: Organisation, admin_client: APIClient -) -> None: - # Given - url = reverse( - "api-v1:organisations:create-or-update-licence", args=[organisation.id] - ) - - licence_data = { - "organisation_name": "Test Organisation", - "plan_id": "Enterprise", - "num_seats": 20, - "num_projects": 3, - } - - licence_json = json.dumps(licence_data) - licence = SimpleUploadedFile( - name="licence.txt", - content=licence_json.encode(), - content_type="text/plain", - ) - - # When - response = admin_client.put( - url, - data={ - "licence": licence, - }, - ) - - # Then - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json() == ["No licence signature file provided."] - assert not OrganisationLicence.objects.filter(organisation=organisation).exists() - - -def test_create_or_update_licence_bad_signature( - organisation: Organisation, - admin_client: APIClient, - settings: SettingsWrapper, -) -> None: - # Given - url = reverse( - "api-v1:organisations:create-or-update-licence", args=[organisation.id] - ) - - licence_data = { - "organisation_name": "Test Organisation", - "plan_id": "Enterprise", - "num_seats": 20, - "num_projects": 3, - } - - licence_json = json.dumps(licence_data) - licence = SimpleUploadedFile( - name="licence.txt", - content=licence_json.encode(), - content_type="text/plain", - ) - licence_signature = SimpleUploadedFile( - name="licence_signature.txt", - content=sign_licence(licence_json).encode(), - content_type="text/plain", - ) - - # Change the public key information so the signature fails - settings.SUBSCRIPTION_LICENCE_PUBLIC_KEY = """ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtKOkPiegKyWdsUcmUOXv -bnunQeG4B+yOw2GG/bfXiG+ec9L2WVlSy5iK/p4AnwsSHj6gnJawHp/YK6wkYcgF -w/l2WI0T9MNsJagN+uxyV27YtWnV50JzOEFyEzSYUZxqKokVce70PypbqfsjASTl -OCJJErEGgIKdHk3T5RpQPigHwh9/a7KiBzV7ktan7KSNkcmketd9Db0eg+KdO1yZ -bNQGDrPMaYXVpfG+Ic2yU7wtCKkYb1/s+JBMkI6a3XH8DhuKq6rSG+GrJttYpjrR -PAhkbx1Jf3FftZf4YL9X3W3ghczPPatemfylyAFiTGH5FrjlhlRJn+8owfWjK3zN -3wIDAQAC ------END PUBLIC KEY----- - """ - - # When - response = admin_client.put( - url, - data={ - "licence": licence, - "licence_signature": licence_signature, - }, - ) - - # Then - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json() == ["Signature failed for licence."] - - assert not OrganisationLicence.objects.filter(organisation=organisation).exists() From 7d8760836b45e1fa2b0c767005c3e435849756da Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 11 Nov 2024 16:23:07 +0000 Subject: [PATCH 40/42] Remove licensing tests --- .../subscriptions/licensing/__init__.py | 0 .../subscriptions/licensing/test_helpers.py | 105 ------------------ 2 files changed, 105 deletions(-) delete mode 100644 api/tests/unit/organisations/subscriptions/licensing/__init__.py delete mode 100644 api/tests/unit/organisations/subscriptions/licensing/test_helpers.py diff --git a/api/tests/unit/organisations/subscriptions/licensing/__init__.py b/api/tests/unit/organisations/subscriptions/licensing/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py b/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py deleted file mode 100644 index dcc44f03358c..000000000000 --- a/api/tests/unit/organisations/subscriptions/licensing/test_helpers.py +++ /dev/null @@ -1,105 +0,0 @@ -import json - -import pytest -from pytest_django.fixtures import SettingsWrapper - -from organisations.subscriptions.licensing.helpers import ( - PrivateKeyMissingError, - create_private_key, - create_public_key, - sign_licence, - verify_signature, -) - - -def test_sign_and_verify_signature_of_licence() -> None: - # Given - licence_content = { - "organisation_name": "Test Organisation", - "plan_id": "Enterprise", - "num_seats": 20, - "num_projects": 3, - "num_api_calls": 3_000_000, - } - - licence = json.dumps(licence_content) - - # When - licence_signature = sign_licence(licence) - signature_verification = verify_signature(licence, licence_signature) - - # Then - assert licence_signature - assert signature_verification is True - - -def test_sign_and_verify_signature_of_licence_when_signature_fails( - settings: SettingsWrapper, -) -> None: - # Given - # Change the public key information so the signature fails - settings.SUBSCRIPTION_LICENCE_PUBLIC_KEY = """ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtKOkPiegKyWdsUcmUOXv -bnunQeG4B+yOw2GG/bfXiG+ec9L2WVlSy5iK/p4AnwsSHj6gnJawHp/YK6wkYcgF -w/l2WI0T9MNsJagN+uxyV27YtWnV50JzOEFyEzSYUZxqKokVce70PypbqfsjASTl -OCJJErEGgIKdHk3T5RpQPigHwh9/a7KiBzV7ktan7KSNkcmketd9Db0eg+KdO1yZ -bNQGDrPMaYXVpfG+Ic2yU7wtCKkYb1/s+JBMkI6a3XH8DhuKq6rSG+GrJttYpjrR -PAhkbx1Jf3FftZf4YL9X3W3ghczPPatemfylyAFiTGH5FrjlhlRJn+8owfWjK3zN -3wIDAQAC ------END PUBLIC KEY----- - """ - - licence_content = { - "organisation_name": "Test Organisation", - "plan_id": "Enterprise", - "num_seats": 20, - "num_projects": 3, - "num_api_calls": 3_000_000, - } - - licence = json.dumps(licence_content) - - # When - licence_signature = sign_licence(licence) - signature_verification = verify_signature(licence, licence_signature) - - # Then - assert signature_verification is False - - -def test_sign_licence_with_missing_private_key(settings: SettingsWrapper) -> None: - # Given - settings.SUBSCRIPTION_LICENCE_PRIVATE_KEY = None - licence_content = { - "organisation_name": "Test Organisation", - "plan_id": "Enterprise", - "num_seats": 20, - "num_projects": 3, - "num_api_calls": 3_000_000, - } - - licence = json.dumps(licence_content) - - # When - with pytest.raises(PrivateKeyMissingError) as exception: - sign_licence(licence) - - # Then - assert str(exception.value) == "Private key is missing" - - -def test_create_public_key() -> None: - # Given / When - public_key = create_public_key() - - # Then - assert "BEGIN PUBLIC KEY" in public_key - - -def test_create_private_key() -> None: - # Given / When - private_key = create_private_key() - - # Then - assert "BEGIN PRIVATE KEY" in private_key From 66791aeba5e7611a827c79dab93aba9b0ec177dd Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Wed, 13 Nov 2024 20:36:56 +0000 Subject: [PATCH 41/42] Add pragma no covers --- api/app/settings/common.py | 2 +- api/organisations/models.py | 6 ++++-- api/organisations/urls.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index c8f6c6004504..116813b29853 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -1293,5 +1293,5 @@ LICENSING_INSTALLED = importlib.util.find_spec("licensing") is not None -if LICENSING_INSTALLED: +if LICENSING_INSTALLED: # pragma: no cover INSTALLED_APPS.append("licensing") diff --git a/api/organisations/models.py b/api/organisations/models.py index 3789f6a17236..76895c8e1fbd 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -415,7 +415,9 @@ def _get_subscription_metadata_for_chargebee(self) -> ChargebeeObjMetadata: return cb_metadata def _get_subscription_metadata_for_self_hosted(self) -> BaseSubscriptionMetadata: - if is_enterprise() and hasattr(self.organisation, "licence"): + if is_enterprise() and hasattr( + self.organisation, "licence" + ): # pragma: no cover licence_information = self.organisation.licence.get_licence_information() return BaseSubscriptionMetadata( seats=licence_information.num_seats, @@ -423,7 +425,7 @@ def _get_subscription_metadata_for_self_hosted(self) -> BaseSubscriptionMetadata audit_log_visibility_days=None, feature_history_visibility_days=None, ) - elif is_enterprise(): + elif is_enterprise(): # pragma: no cover return BaseSubscriptionMetadata( seats=self.max_seats, api_calls=self.max_api_calls, diff --git a/api/organisations/urls.py b/api/organisations/urls.py index c6bb85650032..600beededdf0 100644 --- a/api/organisations/urls.py +++ b/api/organisations/urls.py @@ -154,7 +154,7 @@ ), ] -if settings.LICENSING_INSTALLED: +if settings.LICENSING_INSTALLED: # pragma: no cover from licensing.views import create_or_update_licence urlpatterns.extend( From 0a06ed99aaf2e3002fab1ee3f33fe3951ad951e8 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Thu, 28 Nov 2024 14:06:12 +0000 Subject: [PATCH 42/42] Add comment to elif branch --- api/organisations/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/organisations/models.py b/api/organisations/models.py index 76895c8e1fbd..1e5637df3e8e 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -425,6 +425,9 @@ def _get_subscription_metadata_for_self_hosted(self) -> BaseSubscriptionMetadata audit_log_visibility_days=None, feature_history_visibility_days=None, ) + # TODO: Once we've successfully rolled out licences to enterprises + # remove this branch to force them into the free plan + # if they don't have a licence. elif is_enterprise(): # pragma: no cover return BaseSubscriptionMetadata( seats=self.max_seats,