Skip to content
8 changes: 8 additions & 0 deletions api/app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
]
Expand Down
38 changes: 0 additions & 38 deletions api/organisations/subscriptions/decorators.py

This file was deleted.

60 changes: 60 additions & 0 deletions api/organisations/subscriptions/permissions.py
Original file line number Diff line number Diff line change
@@ -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]:
Comment thread
khvn26 marked this conversation as resolved.
"""
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

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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
)
6 changes: 3 additions & 3 deletions frontend/common/utils/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading