diff --git a/api/app/urls.py b/api/app/urls.py index 253a9e8e6e66..27e2f2111e1f 100644 --- a/api/app/urls.py +++ b/api/app/urls.py @@ -97,6 +97,14 @@ ] + urlpatterns if settings.SAML_INSTALLED: # pragma: no cover + from saml.views import SamlConfigurationViewSet + + from organisations.subscriptions.constants import SubscriptionPlanFamily + from organisations.subscriptions.permissions import require_minimum_plan + + scale_up_permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP) + SamlConfigurationViewSet.permission_classes += [scale_up_permission] + urlpatterns += [ path("api/v1/auth/saml/", include("saml.urls")), ] diff --git a/api/organisations/subscriptions/decorators.py b/api/organisations/subscriptions/decorators.py deleted file mode 100644 index 8ec7524e4291..000000000000 --- a/api/organisations/subscriptions/decorators.py +++ /dev/null @@ -1,38 +0,0 @@ -import typing - -from rest_framework.request import Request - -from organisations.models import Subscription -from organisations.subscriptions.exceptions import InvalidSubscriptionPlanError - - -def require_plan( # type: ignore[no-untyped-def] - valid_plan_ids: typing.Iterable[str], - subscription_retriever: typing.Callable[[Request], Subscription], -): - """ - Decorator to be used on view functions / methods that require a specific plan. - - Will result in 403 if resource requested is not part of an organisation that has - the correct subscription. - """ - - def decorator(func): # type: ignore[no-untyped-def] - def wrapper(*args, **kwargs): # type: ignore[no-untyped-def] - if not (args and isinstance(args[0], Request)): - raise ValueError( - "require_plan decorator must be used on methods / functions whose " - "first argument is a Request object." - ) - - sub = subscription_retriever(args[0]) - if not sub or sub.plan not in valid_plan_ids: # type: ignore[operator] - raise InvalidSubscriptionPlanError( - f"Resource not available on plan {sub.plan}" - ) - - return func(*args, **kwargs) - - return wrapper - - return decorator diff --git a/api/organisations/subscriptions/permissions.py b/api/organisations/subscriptions/permissions.py new file mode 100644 index 000000000000..ca421d0fa3cc --- /dev/null +++ b/api/organisations/subscriptions/permissions.py @@ -0,0 +1,60 @@ +from common.core.utils import is_saas +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.views import APIView + +from organisations.models import Organisation +from organisations.subscriptions.constants import SubscriptionPlanFamily + +_PLAN_RANK = { + SubscriptionPlanFamily.FREE: 0, + SubscriptionPlanFamily.START_UP: 1, + SubscriptionPlanFamily.SCALE_UP: 2, + SubscriptionPlanFamily.ENTERPRISE: 3, +} + + +def require_minimum_plan(minimum: SubscriptionPlanFamily) -> type[BasePermission]: + """ + Return a DRF permission class that requires the organisation associated + with the request to be on `minimum` plan family or higher. + + On non-SaaS deployments (self-hosted OSS / Enterprise), plan gating is + always bypassed. These deployments don't use Chargebee subscriptions — + 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 + """ + min_rank = _PLAN_RANK[minimum] + + def _meets(org: Organisation) -> bool: + return _PLAN_RANK.get(org.subscription.subscription_plan_family, -1) >= min_rank + + class _MinimumPlanPermission(BasePermission): + message = f"This resource requires a {minimum.value} plan or above." + + def has_permission(self, request: Request, view: APIView) -> bool: + if not is_saas(): + return True + 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) + + def has_object_permission( + self, request: Request, view: APIView, obj: object + ) -> bool: + if not is_saas(): + return True + org = getattr(obj, "organisation", None) + return isinstance(org, Organisation) and _meets(org) + + return _MinimumPlanPermission diff --git a/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_decorators.py b/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_decorators.py deleted file mode 100644 index 97bedfb1bf88..000000000000 --- a/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_decorators.py +++ /dev/null @@ -1,42 +0,0 @@ -from unittest.mock import MagicMock - -import pytest -from rest_framework.request import Request - -from organisations.subscriptions.decorators import require_plan -from organisations.subscriptions.exceptions import InvalidSubscriptionPlanError - - -def test_require_plan__invalid_plan__raises_exception(): # type: ignore[no-untyped-def] - # Given - valid_plan_id = "plan-id" - invalid_plan_id = "invalid-plan-id" - - mock_request = MagicMock(spec=Request) - mock_subscription = MagicMock(plan=invalid_plan_id) - - @require_plan([valid_plan_id], lambda v: mock_subscription) # type: ignore[misc] - def test_function(request: Request): # type: ignore[no-untyped-def] - return "foo" - - # When / Then - with pytest.raises(InvalidSubscriptionPlanError): - test_function(mock_request) - - -def test_require_plan__valid_plan__returns_function_result(rf): # type: ignore[no-untyped-def] - # Given - valid_plan_id = "plan-id" - - mock_request = MagicMock(spec=Request) - mock_subscription = MagicMock(plan=valid_plan_id) - - @require_plan([valid_plan_id], lambda v: mock_subscription) # type: ignore[misc] - def test_function(request: Request): # type: ignore[no-untyped-def] - return "foo" - - # When - res = test_function(mock_request) - - # Then - assert res == "foo" diff --git a/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_permissions.py b/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_permissions.py new file mode 100644 index 000000000000..166bf90bf47e --- /dev/null +++ b/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_permissions.py @@ -0,0 +1,177 @@ +from unittest.mock import MagicMock + +import pytest +from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] +from rest_framework.request import Request + +from organisations.models import Organisation, Subscription +from organisations.subscriptions.constants import SubscriptionPlanFamily +from organisations.subscriptions.permissions import require_minimum_plan + + +@pytest.mark.saas_mode +@pytest.mark.parametrize( + "subscription_fixture, expected", + [ + (lazy_fixture("free_subscription"), False), + (lazy_fixture("startup_subscription"), False), + (lazy_fixture("scale_up_subscription"), True), + (lazy_fixture("enterprise_subscription"), True), + ], +) +def test_require_minimum_plan__has_permission__plan_matrix( + organisation: Organisation, + subscription_fixture: Subscription, + expected: bool, +) -> None: + # Given + permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() + request = MagicMock(spec=Request) + request.data = {"organisation": organisation.id} + request.query_params = {} + + # When / Then + assert permission.has_permission(request, MagicMock()) is expected + + +@pytest.mark.saas_mode +@pytest.mark.parametrize( + "subscription_fixture, expected", + [ + (lazy_fixture("free_subscription"), False), + (lazy_fixture("startup_subscription"), False), + (lazy_fixture("scale_up_subscription"), True), + (lazy_fixture("enterprise_subscription"), True), + ], +) +def test_require_minimum_plan__has_object_permission__plan_matrix( + organisation: Organisation, + subscription_fixture: Subscription, + expected: bool, +) -> None: + # Given + permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() + obj = MagicMock() + obj.organisation = organisation + + # When / Then + assert ( + permission.has_object_permission(MagicMock(spec=Request), MagicMock(), obj) + is expected + ) + + +@pytest.mark.saas_mode +@pytest.mark.parametrize("source", ["data", "query_params"]) +def test_require_minimum_plan__organisation_lookup__reads_from_data_and_query_params( + organisation: Organisation, + free_subscription: Subscription, + source: str, +) -> None: + # Given + permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() + request = MagicMock(spec=Request) + request.data = {} + request.query_params = {} + setattr(request, source, {"organisation": organisation.id}) + + # When / Then + assert permission.has_permission(request, MagicMock()) is False + + +@pytest.mark.saas_mode +def test_require_minimum_plan__no_organisation_in_request__defers_to_object_level() -> ( + None +): + # Given + permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() + request = MagicMock(spec=Request) + request.data = {} + request.query_params = {} + + # When / Then + assert permission.has_permission(request, MagicMock()) is True + + +@pytest.mark.saas_mode +def test_require_minimum_plan__unknown_organisation_id__returns_false( + db: None, +) -> None: + # Given + permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() + request = MagicMock(spec=Request) + request.data = {"organisation": 999999} + request.query_params = {} + + # When / Then + assert permission.has_permission(request, MagicMock()) is False + + +@pytest.mark.saas_mode +def test_require_minimum_plan_has_object_permission__obj_without_organisation__returns_false() -> ( + None +): + # Given + permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() + + # When / Then + assert ( + permission.has_object_permission(MagicMock(spec=Request), MagicMock(), object()) + is False + ) + + +@pytest.mark.saas_mode +@pytest.mark.parametrize( + "minimum, subscription_fixture, allowed", + [ + (SubscriptionPlanFamily.START_UP, lazy_fixture("free_subscription"), False), + (SubscriptionPlanFamily.START_UP, lazy_fixture("startup_subscription"), True), + (SubscriptionPlanFamily.SCALE_UP, lazy_fixture("startup_subscription"), False), + (SubscriptionPlanFamily.SCALE_UP, lazy_fixture("scale_up_subscription"), True), + ( + SubscriptionPlanFamily.ENTERPRISE, + lazy_fixture("scale_up_subscription"), + False, + ), + ( + SubscriptionPlanFamily.ENTERPRISE, + lazy_fixture("enterprise_subscription"), + True, + ), + ], +) +def test_require_minimum_plan__plan_hierarchy__honours_ordering( + organisation: Organisation, + minimum: SubscriptionPlanFamily, + subscription_fixture: Subscription, + allowed: bool, +) -> None: + # Given + permission = require_minimum_plan(minimum)() + request = MagicMock(spec=Request) + request.data = {"organisation": organisation.id} + request.query_params = {} + + # When / Then + assert permission.has_permission(request, MagicMock()) is allowed + + +def test_require_minimum_plan__self_hosted__bypasses_check( + organisation: Organisation, + free_subscription: Subscription, +) -> None: + # Given + permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() + request = MagicMock(spec=Request) + request.data = {"organisation": organisation.id} + request.query_params = {} + obj = MagicMock() + obj.organisation = organisation + + # When / Then + assert permission.has_permission(request, MagicMock()) is True + assert ( + permission.has_object_permission(MagicMock(spec=Request), MagicMock(), obj) + is True + ) diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 61d66b67c00f..450ed65d1222 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -517,15 +517,15 @@ const Utils = Object.assign({}, BaseUtils, { case 'RBAC': case 'AUDIT': case '4_EYES_PROJECT': - case '4_EYES': { + case '4_EYES': + case 'SAML': { plan = 'scale-up' break } case 'STALE_FLAGS': case 'REALTIME': case 'METADATA': - case 'RELEASE_PIPELINES': - case 'SAML': { + case 'RELEASE_PIPELINES': { plan = 'enterprise' break }