From c16bf17f945044a0bc33ec058fac56344061b0c3 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Apr 2026 17:19:50 +0100 Subject: [PATCH] feat: gate-scaleup-features-behind-minimum-plan --- api/app/urls.py | 37 ++++ .../subscriptions/permissions.py | 76 +++++++- api/organisations/urls.py | 32 ++++ api/projects/urls.py | 22 +++ .../test_unit_subscriptions_permissions.py | 163 +++++++++++++++++- 5 files changed, 322 insertions(+), 8 deletions(-) diff --git a/api/app/urls.py b/api/app/urls.py index 27e2f2111e1f..3d29086ab1b3 100644 --- a/api/app/urls.py +++ b/api/app/urls.py @@ -111,6 +111,43 @@ if settings.WORKFLOWS_LOGIC_INSTALLED: # pragma: no cover workflow_views = importlib.import_module("workflows_logic.views") + + from features.workflows.core.models import ChangeRequest + from organisations.models import Organisation + from organisations.subscriptions.constants import SubscriptionPlanFamily + from organisations.subscriptions.permissions import ( + organisation_from_environment_api_key, + require_minimum_plan, + ) + + _cr_env_plan_permission = require_minimum_plan( + SubscriptionPlanFamily.SCALE_UP, + get_organisation=organisation_from_environment_api_key, + ) + + def _org_from_change_request(obj: object) -> Organisation | None: + if isinstance(obj, ChangeRequest): + return obj.project.organisation + return None + + _cr_detail_plan_permission = require_minimum_plan( + SubscriptionPlanFamily.SCALE_UP, + get_organisation_from_object=_org_from_change_request, + ) + + workflow_views.ChangeRequestViewSet.permission_classes = [ + *workflow_views.ChangeRequestViewSet.permission_classes, + _cr_detail_plan_permission, + ] + workflow_views.create_change_request.cls.permission_classes = [ + *workflow_views.create_change_request.cls.permission_classes, + _cr_env_plan_permission, + ] + workflow_views.ListChangeRequestsAPIView.permission_classes = [ + *workflow_views.ListChangeRequestsAPIView.permission_classes, + _cr_env_plan_permission, + ] + urlpatterns += [ path("api/v1/features/workflows/", include("workflows_logic.urls")), path( diff --git a/api/organisations/subscriptions/permissions.py b/api/organisations/subscriptions/permissions.py index ca421d0fa3cc..7bc883c6ee86 100644 --- a/api/organisations/subscriptions/permissions.py +++ b/api/organisations/subscriptions/permissions.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from common.core.utils import is_saas from rest_framework.permissions import BasePermission from rest_framework.request import Request @@ -14,7 +16,55 @@ } -def require_minimum_plan(minimum: SubscriptionPlanFamily) -> type[BasePermission]: +def organisation_from_organisation_pk( + request: Request, view: APIView +) -> Organisation | None: + org_pk = view.kwargs.get("organisation_pk") + if not org_pk: + return None + result: Organisation | None = ( + Organisation.objects.select_related("subscription").filter(pk=org_pk).first() + ) + return result + + +def organisation_from_project_pk( + request: Request, view: APIView +) -> Organisation | None: + project_pk = view.kwargs.get("project_pk") + if not project_pk: + return None + result: Organisation | None = ( + Organisation.objects.select_related("subscription") + .filter(projects__pk=project_pk) + .first() + ) + return result + + +def organisation_from_environment_api_key( + request: Request, + view: APIView, +) -> Organisation | None: + api_key = view.kwargs.get("environment_api_key") + if not api_key: + return None + result: Organisation | None = ( + Organisation.objects.select_related("subscription") + .filter(projects__environments__api_key=api_key) + .first() + ) + return result + + +def require_minimum_plan( + minimum: SubscriptionPlanFamily, + *, + get_organisation: Callable[[Request, APIView], Organisation | None] | None = None, + get_organisation_from_object: ( + Callable[[object], Organisation | None] | None + ) = None, +) -> type[BasePermission]: """ Return a DRF permission class that requires the organisation associated with the request to be on `minimum` plan family or higher. @@ -24,9 +74,11 @@ def require_minimum_plan(minimum: SubscriptionPlanFamily) -> type[BasePermission entitlements are handled via the enterprise licence file instead, so `Subscription.plan` is typically `"free"` and not meaningful. - On SaaS, the organisation is read from: - - `obj.organisation` for detail actions (via `has_object_permission`) - - `request.data["organisation"]` or `?organisation=` for list/create + On SaaS, the organisation is resolved via (in order of priority): + - The `get_organisation(request, view)` callback, if provided. + - `request.data["organisation"]` or `?organisation=` for list/create. + - The `get_organisation_from_object(obj)` callback (object-level only). + - `obj.organisation` for detail actions (via `has_object_permission`). """ min_rank = _PLAN_RANK[minimum] @@ -39,12 +91,15 @@ class _MinimumPlanPermission(BasePermission): def has_permission(self, request: Request, view: APIView) -> bool: if not is_saas(): return True + + if get_organisation is not None: + org = get_organisation(request, view) + return org is not None and _meets(org) + org_id = request.data.get("organisation") or request.query_params.get( "organisation" ) if not org_id: - # defer to has_object_permission for detail actions; - # list/create without an org will be caught by the view's validation return True org = Organisation.objects.filter(id=org_id).first() return org is not None and _meets(org) @@ -54,7 +109,14 @@ def has_object_permission( ) -> bool: if not is_saas(): return True - org = getattr(obj, "organisation", None) + + if get_organisation is not None: + org = get_organisation(request, view) + elif get_organisation_from_object is not None: + org = get_organisation_from_object(obj) + else: + org = getattr(obj, "organisation", None) + return isinstance(org, Organisation) and _meets(org) return _MinimumPlanPermission diff --git a/api/organisations/urls.py b/api/organisations/urls.py index 05a0ed976096..9070c7fcf4c2 100644 --- a/api/organisations/urls.py +++ b/api/organisations/urls.py @@ -19,6 +19,11 @@ ) from integrations.grafana.views import GrafanaOrganisationConfigurationViewSet from metadata.views import MetaDataModelFieldViewSet +from organisations.subscriptions.constants import SubscriptionPlanFamily +from organisations.subscriptions.permissions import ( + organisation_from_organisation_pk, + require_minimum_plan, +) from organisations.views import ( OrganisationAPIUsageNotificationView, OrganisationWebhookViewSet, @@ -79,6 +84,15 @@ "audit", OrganisationAuditLogViewSet, basename="audit-log" ) +_audit_plan_permission = require_minimum_plan( + SubscriptionPlanFamily.SCALE_UP, + get_organisation=organisation_from_organisation_pk, +) +OrganisationAuditLogViewSet.permission_classes = [ + *OrganisationAuditLogViewSet.permission_classes, + _audit_plan_permission, +] + organisations_router.register( r"integrations/grafana", GrafanaOrganisationConfigurationViewSet, @@ -190,6 +204,24 @@ UserRoleViewSet, ) + _rbac_plan_permission = require_minimum_plan( + SubscriptionPlanFamily.SCALE_UP, + get_organisation=organisation_from_organisation_pk, + ) + for _vs in ( + RoleViewSet, + UserRoleViewSet, + GroupRoleViewSet, + RoleEnvironmentPermissionsViewSet, + RoleOrganisationPermissionViewSet, + RoleProjectPermissionsViewSet, + MasterAPIKeyRoleViewSet, + RolesbyMasterAPIPrefixViewSet, + RolesByUserViewSet, + RolesByGroupViewSet, + ): + _vs.permission_classes = [*_vs.permission_classes, _rbac_plan_permission] + organisations_router.register("roles", RoleViewSet, basename="organisation-roles") nested_user_roles_routes = routers.NestedSimpleRouter( parent_router=organisations_router, parent_prefix=r"users", lookup="user" diff --git a/api/projects/urls.py b/api/projects/urls.py index 80d4e8d4bd14..cec84d8c7487 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -29,6 +29,11 @@ from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet from metadata.views import ProjectMetadataFieldViewSet +from organisations.subscriptions.constants import SubscriptionPlanFamily +from organisations.subscriptions.permissions import ( + organisation_from_project_pk, + require_minimum_plan, +) from projects.tags.views import TagViewSet from segments.views import SegmentViewSet @@ -86,6 +91,14 @@ ProjectAuditLogViewSet, basename="project-audit", ) + +ProjectAuditLogViewSet.permission_classes = [ + *ProjectAuditLogViewSet.permission_classes, + require_minimum_plan( + SubscriptionPlanFamily.SCALE_UP, + get_organisation=organisation_from_project_pk, + ), +] projects_router.register( "feature-health/providers", FeatureHealthProviderViewSet, @@ -104,6 +117,15 @@ if settings.WORKFLOWS_LOGIC_INSTALLED: # pragma: no cover workflow_views = importlib.import_module("workflows_logic.views") + + workflow_views.ProjectChangeRequestViewSet.permission_classes = [ + *workflow_views.ProjectChangeRequestViewSet.permission_classes, + require_minimum_plan( + SubscriptionPlanFamily.SCALE_UP, + get_organisation=organisation_from_project_pk, + ), + ] + projects_router.register( r"change-requests", workflow_views.ProjectChangeRequestViewSet, diff --git a/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_permissions.py b/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_permissions.py index 166bf90bf47e..c7c60fff9ed8 100644 --- a/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_permissions.py +++ b/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_permissions.py @@ -4,9 +4,16 @@ from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] from rest_framework.request import Request +from environments.models import Environment from organisations.models import Organisation, Subscription from organisations.subscriptions.constants import SubscriptionPlanFamily -from organisations.subscriptions.permissions import require_minimum_plan +from organisations.subscriptions.permissions import ( + organisation_from_environment_api_key, + organisation_from_organisation_pk, + organisation_from_project_pk, + require_minimum_plan, +) +from projects.models import Project @pytest.mark.saas_mode @@ -175,3 +182,157 @@ def test_require_minimum_plan__self_hosted__bypasses_check( permission.has_object_permission(MagicMock(spec=Request), MagicMock(), obj) is True ) + + +@pytest.mark.parametrize( + "resolver, kwarg_key", + [ + (organisation_from_organisation_pk, "organisation_pk"), + (organisation_from_project_pk, "project_pk"), + (organisation_from_environment_api_key, "environment_api_key"), + ], +) +def test_organisation_resolver__missing_kwarg__returns_none( + db: None, + resolver: object, + kwarg_key: str, +) -> None: + # Given + view = MagicMock() + view.kwargs = {} + + # When / Then + assert resolver(MagicMock(spec=Request), view) is None # type: ignore[operator] + + +def test_organisation_resolver__valid_kwarg__returns_organisation( + organisation: Organisation, + project: Project, + environment: Environment, +) -> None: + # Given + cases = [ + (organisation_from_organisation_pk, {"organisation_pk": organisation.pk}), + (organisation_from_project_pk, {"project_pk": project.pk}), + ( + organisation_from_environment_api_key, + {"environment_api_key": environment.api_key}, + ), + ] + + for resolver, kwargs in cases: + view = MagicMock() + view.kwargs = kwargs + + # When / Then + assert resolver(MagicMock(spec=Request), view) == organisation + + +@pytest.mark.saas_mode +@pytest.mark.parametrize( + "method, subscription_fixture, expected", + [ + ("has_permission", lazy_fixture("free_subscription"), False), + ("has_permission", lazy_fixture("scale_up_subscription"), True), + ("has_object_permission", lazy_fixture("free_subscription"), False), + ("has_object_permission", lazy_fixture("scale_up_subscription"), True), + ], +) +def test_require_minimum_plan__get_organisation_callback__checks_plan( + organisation: Organisation, + subscription_fixture: Subscription, + method: str, + expected: bool, +) -> None: + # Given + permission = require_minimum_plan( + SubscriptionPlanFamily.SCALE_UP, + get_organisation=lambda req, view: organisation, + )() + request = MagicMock(spec=Request) + request.data = {} + request.query_params = {} + + # When / Then + assert ( + getattr(permission, method)( + request, MagicMock(), *(() if method == "has_permission" else (object(),)) + ) + is expected + ) + + +@pytest.mark.saas_mode +def test_require_minimum_plan_has_permission__get_organisation_returns_none__returns_false( + db: None, +) -> None: + # Given + permission = require_minimum_plan( + SubscriptionPlanFamily.SCALE_UP, + get_organisation=lambda req, view: None, + )() + request = MagicMock(spec=Request) + request.data = {} + request.query_params = {} + + # When / Then + assert permission.has_permission(request, MagicMock()) is False + + +@pytest.mark.saas_mode +@pytest.mark.parametrize( + "subscription_fixture, expected", + [ + (lazy_fixture("free_subscription"), False), + (lazy_fixture("scale_up_subscription"), True), + ], +) +def test_require_minimum_plan_has_object_permission__get_organisation_from_object__checks_plan( + organisation: Organisation, + subscription_fixture: Subscription, + expected: bool, +) -> None: + # Given + permission = require_minimum_plan( + SubscriptionPlanFamily.SCALE_UP, + get_organisation_from_object=lambda o: organisation, + )() + + # When / Then + assert ( + permission.has_object_permission(MagicMock(spec=Request), MagicMock(), object()) + is expected + ) + + +@pytest.mark.saas_mode +def test_require_minimum_plan_has_permission__no_organisation_resolver__defers_to_object_level( + db: None, +) -> None: + # Given + permission = require_minimum_plan( + SubscriptionPlanFamily.SCALE_UP, + get_organisation_from_object=lambda o: None, + )() + request = MagicMock(spec=Request) + request.data = {} + request.query_params = {} + + # When / Then + assert permission.has_permission(request, MagicMock()) is True + + +def test_require_minimum_plan__self_hosted_with_callbacks__bypasses_check( + organisation: Organisation, + free_subscription: Subscription, +) -> None: + # Given + permission = require_minimum_plan( + SubscriptionPlanFamily.SCALE_UP, + get_organisation=lambda req, view: organisation, + )() + request = MagicMock(spec=Request) + + # When / Then + assert permission.has_permission(request, MagicMock()) is True + assert permission.has_object_permission(request, MagicMock(), object()) is True